Skip to content

Commit 2bbd4e6

Browse files
committed
Add connectivity gap-filling (MILP) against template models
1 parent 5e36aae commit 2bbd4e6

3 files changed

Lines changed: 290 additions & 0 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
"""Connectivity gap-filling against template models.
2+
3+
:func:`connect_blocked_reactions` adds the fewest (lowest-penalty) template reactions so
4+
reactions blocked in a draft can carry flux. For the other gap-fill flavour (fill until
5+
the objective is feasible) use ``cobra.flux_analysis.gapfill``.
6+
"""
7+
from raven_python.gapfilling.fill import GapFillResult, connect_blocked_reactions
8+
9+
__all__ = ["GapFillResult", "connect_blocked_reactions"]
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
"""Connectivity gap-filling: add the fewest template reactions so reactions that are
2+
*blocked* in a draft can carry flux.
3+
4+
For the other gap-filling flavour (add the fewest template reactions until the model's
5+
own objective becomes feasible) use ``cobra.flux_analysis.gapfill`` — just align the
6+
template's metabolite ids to the draft first, since cobra matches by id.
7+
8+
It solves an MILP: pick the minimum-penalty subset of template reactions such that the
9+
blocked (irreversible) draft reactions can carry flux at steady state. Template
10+
metabolites are matched to the draft by ``name[compartment]`` (via
11+
:func:`add_reactions_from_model`), so templates in a different identifier namespace
12+
than the model still work. Per-reaction ``scores`` (higher = prefer to include) map to
13+
RAVEN's ``rxnScores``; the MILP minimises the penalty ``-score`` (default penalty
14+
``1.0``, i.e. minimise the number of reactions added).
15+
"""
16+
from __future__ import annotations
17+
18+
from collections.abc import Iterable
19+
from dataclasses import dataclass
20+
21+
import cobra
22+
from cobra.flux_analysis import find_blocked_reactions, flux_variability_analysis
23+
24+
from raven_python.manipulation.transfer import add_reactions_from_model
25+
26+
27+
@dataclass
28+
class GapFillResult:
29+
"""Outcome of a connectivity gap-fill.
30+
31+
``added_reactions`` are the template reaction ids added to ``model``;
32+
``newly_connected`` are draft reactions that were blocked but can now carry flux;
33+
``cannot_connect`` are blocked reactions left unconnectable.
34+
"""
35+
36+
added_reactions: list[str]
37+
newly_connected: list[str]
38+
cannot_connect: list[str]
39+
model: cobra.Model
40+
41+
42+
def _as_models(templates: cobra.Model | Iterable[cobra.Model]) -> list[cobra.Model]:
43+
return [templates] if isinstance(templates, cobra.Model) else list(templates)
44+
45+
46+
def _merge_templates(model: cobra.Model, templates: list[cobra.Model]) -> tuple[cobra.Model, list[str]]:
47+
"""Copy every template reaction (new ones only) into a working copy of ``model``.
48+
49+
Returns the working model and the ids of the reactions that came from templates
50+
(the gap-fill candidates). Metabolites are matched by ``name[compartment]``.
51+
"""
52+
working = model.copy()
53+
template_ids: list[str] = []
54+
for template in templates:
55+
new = [r.id for r in template.reactions if r.id not in working.reactions]
56+
if new:
57+
added = add_reactions_from_model(working, template, new, genes=False, note=None)
58+
template_ids += [r.id for r in added]
59+
return working, template_ids
60+
61+
62+
def _solve_min_templates(
63+
working: cobra.Model,
64+
template_ids: list[str],
65+
*,
66+
scores: dict[str, float] | None,
67+
penalty: float,
68+
allow_net_production: bool,
69+
) -> set[str] | None:
70+
"""MILP: minimum-penalty template reactions making ``working`` feasible.
71+
72+
The requirement (here, forced flux through the blocked reactions) must already be
73+
imposed on ``working``. Returns the template reaction ids to keep, or ``None`` if
74+
the problem is infeasible.
75+
"""
76+
prob = working.problem
77+
indicators: dict[str, object] = {}
78+
extra = []
79+
for rid in template_ids:
80+
rxn = working.reactions.get_by_id(rid)
81+
y = prob.Variable(f"_gf_keep_{rid}", type="binary")
82+
indicators[rid] = y
83+
# Flux is confined to [lb*y, ub*y]: zero unless the reaction is kept (y=1).
84+
extra.append(prob.Constraint(rxn.flux_expression - rxn.upper_bound * y, ub=0, name=f"_gf_ub_{rid}"))
85+
extra.append(prob.Constraint(rxn.flux_expression - rxn.lower_bound * y, lb=0, name=f"_gf_lb_{rid}"))
86+
working.add_cons_vars(list(indicators.values()) + extra)
87+
88+
if allow_net_production: # relax steady state to Sv >= 0 (mets may accumulate)
89+
for met in working.metabolites:
90+
working.constraints[met.id].ub = None
91+
92+
def pen(rid: str) -> float:
93+
return -scores[rid] if scores and rid in scores else penalty
94+
95+
working.objective = prob.Objective(
96+
sum(pen(rid) * indicators[rid] for rid in template_ids), direction="min"
97+
)
98+
working.slim_optimize()
99+
if working.solver.status != "optimal":
100+
return None
101+
return {rid for rid, y in indicators.items() if (y.primal or 0) > 0.5}
102+
103+
104+
def _build_filled(model: cobra.Model, templates: list[cobra.Model], chosen: set[str]) -> cobra.Model:
105+
filled = model.copy()
106+
remaining = set(chosen)
107+
for template in templates:
108+
ids = [r for r in remaining if r in template.reactions]
109+
if ids:
110+
add_reactions_from_model(filled, template, ids, genes=False, note="Added by connect_blocked_reactions")
111+
remaining -= set(ids)
112+
return filled
113+
114+
115+
def connect_blocked_reactions(
116+
model: cobra.Model,
117+
templates: cobra.Model | Iterable[cobra.Model],
118+
*,
119+
scores: dict[str, float] | None = None,
120+
penalty: float = 1.0,
121+
allow_net_production: bool = False,
122+
eps: float = 1.0,
123+
) -> GapFillResult:
124+
"""Add template reactions so blocked draft reactions can carry flux.
125+
126+
Finds reactions that
127+
cannot carry flux in ``model``, then adds the minimum-penalty set of template
128+
reactions that lets the (irreversible) ones carry flux, and returns the filled
129+
model. Like RAVEN, only irreversible blocked reactions are forced — reversible
130+
ones can carry flux trivially in the split formulation, so forcing them is
131+
uninformative.
132+
133+
For the *other* gap-filling flavour — adding reactions to make the model's
134+
objective feasible — use ``cobra.flux_analysis.gapfill`` after aligning the
135+
template's metabolite ids to the draft.
136+
137+
The draft is expected to have exchange reactions for its nutrients (otherwise most
138+
reactions are trivially blocked).
139+
"""
140+
templates = _as_models(templates)
141+
blocked = set(find_blocked_reactions(model))
142+
candidates = [r for r in blocked if model.reactions.get_by_id(r).lower_bound >= 0]
143+
144+
working, template_ids = _merge_templates(model, templates)
145+
146+
target: list[str] = []
147+
if candidates:
148+
fva = flux_variability_analysis(working, reaction_list=candidates, fraction_of_optimum=0.0)
149+
# A reaction can be missing from the FVA frame if the solver dropped it
150+
# (e.g. the reaction was eliminated upstream); treat that as "unreachable"
151+
# rather than letting the KeyError propagate.
152+
target = [
153+
r for r in candidates
154+
if r in fva.index and fva.at[r, "maximum"] > eps
155+
]
156+
157+
cannot = sorted(blocked - set(target))
158+
if not target:
159+
return GapFillResult([], [], cannot, model.copy())
160+
161+
for rid in target:
162+
working.reactions.get_by_id(rid).lower_bound = eps
163+
chosen = _solve_min_templates(
164+
working, template_ids, scores=scores, penalty=penalty,
165+
allow_net_production=allow_net_production,
166+
)
167+
if chosen is None:
168+
raise RuntimeError(
169+
"Gap-filling is infeasible: the blocked reactions cannot all carry flux "
170+
"even with every template reaction added."
171+
)
172+
return GapFillResult(sorted(chosen), sorted(target), cannot, _build_filled(model, templates, chosen))

tests/test_gapfilling.py

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
"""Tests for connectivity gap-filling (gapfilling/fill.py, Phase 4b)."""
2+
import cobra
3+
import pytest
4+
5+
from raven_python.gapfilling import GapFillResult, connect_blocked_reactions
6+
7+
8+
def _met(mid):
9+
return cobra.Metabolite(mid, name=mid, compartment="c")
10+
11+
12+
@pytest.fixture
13+
def draft_and_template():
14+
"""Draft: EX_A -> A -> B (r1), but B has no consumer, so r1 is blocked.
15+
16+
Template supplies B -> C (r2) and an exchange for C, which unblocks r1.
17+
"""
18+
A, B = _met("A_c"), _met("B_c")
19+
draft = cobra.Model("draft")
20+
exa = cobra.Reaction("EX_A", lower_bound=-10, upper_bound=1000)
21+
exa.add_metabolites({A: 1})
22+
r1 = cobra.Reaction("r1", lower_bound=0, upper_bound=1000) # A -> B, irreversible
23+
r1.add_metabolites({A: -1, B: 1})
24+
draft.add_reactions([exa, r1])
25+
26+
template = cobra.Model("template")
27+
r2 = cobra.Reaction("r2", lower_bound=0, upper_bound=1000) # B -> C
28+
r2.add_metabolites({_met("B_c"): -1, _met("C_c"): 1})
29+
exc = cobra.Reaction("EX_C", lower_bound=-1000, upper_bound=1000)
30+
exc.add_metabolites({_met("C_c"): -1})
31+
extra = cobra.Reaction("r_unneeded", lower_bound=0, upper_bound=1000) # D -> E, irrelevant
32+
extra.add_metabolites({_met("D_c"): -1, _met("E_c"): 1})
33+
template.add_reactions([r2, exc, extra])
34+
return draft, template
35+
36+
37+
# --------------------------------------------------------------------------- #
38+
# Connectivity gap-fill
39+
# --------------------------------------------------------------------------- #
40+
def test_fill_gaps_connects_blocked_reaction(draft_and_template):
41+
draft, template = draft_and_template
42+
assert "r1" in cobra.flux_analysis.find_blocked_reactions(draft) # precondition
43+
44+
res = connect_blocked_reactions(draft, template)
45+
assert isinstance(res, GapFillResult)
46+
assert "r1" in res.newly_connected
47+
assert set(res.added_reactions) == {"r2", "EX_C"} # both needed to drain B
48+
assert "r_unneeded" not in res.added_reactions # irrelevant template rxn not added
49+
50+
51+
def test_fill_gaps_returns_working_model_that_unblocks(draft_and_template):
52+
draft, template = draft_and_template
53+
res = connect_blocked_reactions(draft, template)
54+
assert {"r2", "EX_C"} <= {r.id for r in res.model.reactions}
55+
assert "r1" not in cobra.flux_analysis.find_blocked_reactions(res.model)
56+
# original draft is untouched
57+
assert "r2" not in {r.id for r in draft.reactions}
58+
59+
60+
def test_fill_gaps_nothing_to_do_when_unblocked(draft_and_template):
61+
draft, template = draft_and_template
62+
# give the draft its own drain so r1 is not blocked
63+
drain = cobra.Reaction("EX_B", lower_bound=-1000, upper_bound=1000)
64+
drain.add_metabolites({draft.metabolites.B_c: -1})
65+
draft.add_reactions([drain])
66+
res = connect_blocked_reactions(draft, template)
67+
assert res.added_reactions == []
68+
assert res.newly_connected == []
69+
70+
71+
def test_fill_gaps_scores_prefer_higher_scored_reactions():
72+
# Two alternative single-reaction drains for B; scores should pick the preferred one.
73+
A, B = _met("A_c"), _met("B_c")
74+
draft = cobra.Model("draft")
75+
exa = cobra.Reaction("EX_A", lower_bound=-10, upper_bound=1000)
76+
exa.add_metabolites({A: 1})
77+
r1 = cobra.Reaction("r1", lower_bound=0, upper_bound=1000)
78+
r1.add_metabolites({A: -1, B: 1})
79+
draft.add_reactions([exa, r1])
80+
template = cobra.Model("t")
81+
d1 = cobra.Reaction("drain1", lower_bound=-1000, upper_bound=1000)
82+
d1.add_metabolites({_met("B_c"): -1})
83+
d2 = cobra.Reaction("drain2", lower_bound=-1000, upper_bound=1000)
84+
d2.add_metabolites({_met("B_c"): -1})
85+
template.add_reactions([d1, d2])
86+
# Scores are penalties (higher = preferred = cheaper to include); only one drain
87+
# is needed, so the less-penalised drain1 is chosen.
88+
res = connect_blocked_reactions(draft, template, scores={"drain1": -1.0, "drain2": -5.0})
89+
assert res.added_reactions == ["drain1"]
90+
91+
92+
def test_unconnectable_reaction_reported_not_added():
93+
# A blocked irreversible reaction that no template can connect: reported, no adds.
94+
A, B = _met("A_c"), _met("B_c")
95+
draft = cobra.Model("draft")
96+
exa = cobra.Reaction("EX_A", lower_bound=-10, upper_bound=1000)
97+
exa.add_metabolites({A: 1})
98+
r1 = cobra.Reaction("r1", lower_bound=0, upper_bound=1000) # A -> B, B has no drain
99+
r1.add_metabolites({A: -1, B: 1})
100+
draft.add_reactions([exa, r1])
101+
template = cobra.Model("t") # offers nothing that can drain B
102+
noise = cobra.Reaction("noise", lower_bound=0, upper_bound=1000)
103+
noise.add_metabolites({_met("X_c"): -1, _met("Y_c"): 1})
104+
template.add_reactions([noise])
105+
106+
res = connect_blocked_reactions(draft, template)
107+
assert res.added_reactions == []
108+
assert res.newly_connected == []
109+
assert "r1" in res.cannot_connect

0 commit comments

Comments
 (0)