@dsh-bio/dsh-bio-gem 0.1.3 → 0.1.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +60 -8
- package/docs/ARCHITECTURE.md +145 -116
- package/docs/DECISIONS-2026-09-21.md +76 -0
- package/docs/releases/v0.1.12.md +43 -0
- package/package.json +5 -3
- package/python/benchmark.py +3 -3
- package/python/biomass_tools.py +2 -2
- package/python/bootstrap_carveme.py +259 -0
- package/python/build.py +10 -2
- package/python/coherence.py +159 -0
- package/python/double_knockout.py +1 -1
- package/python/essential_scan.py +1 -1
- package/python/gapfind.py +413 -396
- package/python/gem_ops.py +64 -1
- package/python/l3_fix.py +4 -4
- package/python/ledger.py +1 -1
- package/python/model_card.py +3 -2
- package/python/precursor_scan.py +127 -0
- package/python/quality.py +577 -0
- package/python/sampling.py +269 -0
- package/python/sensitivity.py +2 -2
- package/python/validate.py +435 -409
- package/skills/gem-expert.md +3 -1
- package/src/capabilities.js +152 -0
- package/src/index.js +21 -2
- package/src/integration.js +507 -0
- package/src/jobs.js +5 -21
- package/src/python.js +113 -17
- package/src/tools.js +98 -22
package/python/gapfind.py
CHANGED
|
@@ -1,397 +1,414 @@
|
|
|
1
|
-
# gapfind.py — dsh-bio-gem 缺口分级诊断(L1 缺交换 / L2 缺转运 / L3 内部路径)
|
|
2
|
-
# 规格: docs/ARCHITECTURE.md §6;已知规律(P1 实测):多数"不能利用某碳源"缺口是 L1/L2 而非 L3。
|
|
3
|
-
# 用法: find_gaps(model, medium=None, substrates=None) -> {"L1": [...], "L2": [...], "L3": [...]}
|
|
4
|
-
import os
|
|
5
|
-
import re
|
|
6
|
-
import sys
|
|
7
|
-
import cobra
|
|
8
|
-
|
|
9
|
-
# Python -I isolated 模式下脚本目录不进 sys.path——显式插入以导入同目录模块
|
|
10
|
-
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
11
|
-
|
|
12
|
-
EX_PREFIX = ("EX_", "DM_", "SK_")
|
|
13
|
-
|
|
14
|
-
# 培养基常见成分别名(自然名 -> 模型代谢物名,去 -e0 后缀的小写形式)
|
|
15
|
-
SYN = {
|
|
16
|
-
"orthophosphate": "phosphate", "oxygen": "o2", "l-ornithine": "ornithine",
|
|
17
|
-
"d-galactose": "galactose", "d-xylose": "xylose", "malate": "l-malate",
|
|
18
|
-
"mannose": "d-mannose", "ribose": "d-ribose", "mn": "mn2+",
|
|
19
|
-
"ca": "ca2+", "cl": "cl-", "k": "k+", "mg": "mg",
|
|
20
|
-
"sodium cation": "na+", "fe3+": "fe3", "fe2+": "fe2+",
|
|
21
|
-
"copper": "cu2+", "zinc cation": "zn2+", "biotin": "biot",
|
|
22
|
-
"thiamine": "thiamin", "raffinose": "trhl", "alpha,alpha-trehalose": "trhl",
|
|
23
|
-
"trehalose": "trhl", "4-aminobutanoate": "gaba", "akg": "2-oxoglutarate",
|
|
24
|
-
"fumarate": "fumarate", "beta-d-glucose": "d-glucose", "urea": "urea",
|
|
25
|
-
"galactonate": "d-galactonate", "lactose": "beta-lactose",
|
|
26
|
-
"mantiol": "d-mannitol", "maltotriose": "amylotriose",
|
|
27
|
-
"sn-glycerol 3-phosphate": "glycerol-3-phosphate",
|
|
28
|
-
"malic acid": "l-malate", "gluconate": "d-gluconate",
|
|
29
|
-
"glucose 1-phosphate": "glucose-1-phosphate", "glumate": "l-glutamate",
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
# CarveMe/BiGG 风格别名(跨引擎:gapseq 缩写 -> BiGG 全名;用于 resolve_medium)
|
|
33
|
-
CARVE_ALIAS = {
|
|
34
|
-
"ca2+": "calcium", "k+": "potassium", "mg2+": "magnesium", "na+": "sodium",
|
|
35
|
-
"mn2+": "manganese", "zn2+": "zinc", "ni2+": "nickel", "cu2+": "copper",
|
|
36
|
-
"cl-": "chloride", "nh3": "ammonium", "nh4+": "ammonium", "ammonium": "ammonium",
|
|
37
|
-
"o2": "o2", "h2o": "h2o", "co2": "co2", "cobalt": "co2+", "co2+": "co2+",
|
|
38
|
-
"fe2+": "fe2+", "fe3+": "fe3+", "h+": "h+", "h": "h+",
|
|
39
|
-
"d-glucose": "d-glucose", "phosphate": "phosphate", "sulfate": "sulfate",
|
|
40
|
-
"glucose": "d-glucose", "ammonia": "ammonium",
|
|
41
|
-
# gapseq 命名(无 + 后缀)与 CarveMe 全名双候选
|
|
42
|
-
"mg": ["mg", "magnesium"], "mg2+": ["magnesium", "mg"],
|
|
43
|
-
"mn": ["mn2+", "manganese"], "zn": ["zn2+", "zinc"], "ca": ["ca2+", "calcium"],
|
|
44
|
-
"k": ["k+", "potassium"], "na": ["na+", "sodium"], "cl": ["cl-", "chloride"],
|
|
45
|
-
"fe": ["fe2+", "iron"],
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
# 常用介质预设(自然名成分 -> lb)。agent 只需传 {"medium_name": "AB"} 即可
|
|
49
|
-
# 获得完整成分(金属离子绝不能省——gapseq 生物质方程直接消耗,缺金属生长恒 0)。
|
|
50
|
-
MEDIA_PRESETS = {
|
|
51
|
-
"AB": {
|
|
52
|
-
"D-Glucose": -5, "NH3": -10, "O2": -12.5, "CO2": -15, "H+": -20, "H2O": -100,
|
|
53
|
-
"Phosphate": -10, "Sulfate": -10, "Cl-": -10, "Mn2+": -10, "Zn2+": -10,
|
|
54
|
-
"Co2+": -10, "Ni2+": -1, "Fe3+": -0.1, "Fe2+": -10, "Ca2+": -10, "Cu2+": -10,
|
|
55
|
-
"K+": -10, "Mg2+": -10, "Na+": -10,
|
|
56
|
-
},
|
|
57
|
-
"M9": {}, # 动态:由 _m9_preset() 从 carveme media_db 提取(BiGG ID 形式)
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
def _m9_preset():
|
|
62
|
-
"""M9 预设:从 carveme media_db.tsv 提取(BiGG compound 名 -> EX_<c>_e,lb -10)。
|
|
63
|
-
找不到 carveme 时回退常用 M9 成分(BiGG ID 静态表)。"""
|
|
64
|
-
import csv
|
|
65
|
-
home = os.path.expanduser("~")
|
|
66
|
-
db = os.path.join(home, ".dsh", "dsh-bio-gem", "venv-carveme", "Lib",
|
|
67
|
-
"site-packages", "carveme", "data", "input", "media_db.tsv")
|
|
68
|
-
comps = set()
|
|
69
|
-
if os.path.exists(db):
|
|
70
|
-
with open(db, encoding="utf-8") as f:
|
|
71
|
-
rd = csv.DictReader(f, delimiter="\t")
|
|
72
|
-
for row in rd:
|
|
73
|
-
if row.get("medium") == "M9" and row.get("compound"):
|
|
74
|
-
comps.add(row["compound"].strip())
|
|
75
|
-
if comps:
|
|
76
|
-
return {"EX_" + c + "_e": -10.0 for c in sorted(comps)}
|
|
77
|
-
return {"EX_glc__D_e": -10.0, "EX_nh4_e": -10.0, "EX_o2_e": -12.5,
|
|
78
|
-
"EX_pi_e": -10.0, "EX_so4_e": -10.0, "EX_k_e": -10.0,
|
|
79
|
-
"EX_mg2_e": -10.0, "EX_ca2_e": -10.0, "EX_fe2_e": -10.0,
|
|
80
|
-
"EX_fe3_e": -0.1, "EX_mn2_e": -10.0, "EX_zn2_e": -10.0,
|
|
81
|
-
"EX_cobalt2_e": -10.0, "EX_ni2_e": -10.0, "EX_cu2_e": -10.0,
|
|
82
|
-
"EX_cl_e": -10.0, "EX_na1_e": -10.0, "EX_h2o_e": -100.0,
|
|
83
|
-
"EX_h_e": -20.0, "EX_co2_e": -15.0}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
def expand_medium(medium):
|
|
87
|
-
"""medium 展开:支持 {"medium_name": "AB", ...覆盖成分};M9 动态提取。
|
|
88
|
-
返回 (merged_dict, preset_name_or_None)。"""
|
|
89
|
-
if not medium:
|
|
90
|
-
return medium or {}, None
|
|
91
|
-
med = dict(medium)
|
|
92
|
-
name = med.pop("medium_name", None)
|
|
93
|
-
merged = {}
|
|
94
|
-
if name:
|
|
95
|
-
if name in MEDIA_PRESETS:
|
|
96
|
-
preset = MEDIA_PRESETS[name]
|
|
97
|
-
if name == "M9" and not preset:
|
|
98
|
-
preset = _m9_preset()
|
|
99
|
-
merged.update(preset)
|
|
100
|
-
else:
|
|
101
|
-
pass # 未知预设:保留用户成分,调用方可记 unresolved
|
|
102
|
-
merged.update(med)
|
|
103
|
-
return merged, name
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
# 代谢物 c0 短名别名(自然名/常用名 -> 模型胞内代谢物名;用于 L1 名字补洞)
|
|
107
|
-
# 实测:gapseq 用 BiGG 短名(D-Gluconate-c0 名存为 'GLCN-c0'),SYN 只管 EX 名映射
|
|
108
|
-
MET_ALIAS = {
|
|
109
|
-
"gluconate": "glcn", "d-gluconate": "glcn", "6-phospho-d-gluconate": "6pgc",
|
|
110
|
-
"sucrose": "sucrose", "glucose-1-phosphate": "glucose-1-phosphate",
|
|
111
|
-
"d-glucose-1-phosphate": "glucose-1-phosphate", "g1p": "glucose-1-phosphate",
|
|
112
|
-
"d-glucose": "d-glucose", "glucose": "d-glucose",
|
|
113
|
-
"l-malate": "mal__l", "malate": "mal__l", "malic acid": "mal__l",
|
|
114
|
-
"citrate": "cit", "succinate": "succ", "d-ribose": "rib__d", "ribose": "rib__d",
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
def norm(s):
|
|
118
|
-
return "".join(ch for ch in (s or "").strip().lower() if ch.isalnum() or ch in "+-")
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
def build_met_index(m, compartment="c0"):
|
|
122
|
-
"""胞内代谢物名索引(去 -c0 后缀小写)-> met id。"""
|
|
123
|
-
idx = {}
|
|
124
|
-
for x in m.metabolites:
|
|
125
|
-
if x.compartment == compartment:
|
|
126
|
-
nm = (x.name or "").strip().lower()
|
|
127
|
-
if nm.endswith("-c0"):
|
|
128
|
-
nm = nm[:-3]
|
|
129
|
-
if nm:
|
|
130
|
-
idx.setdefault(norm(nm), x.id)
|
|
131
|
-
return idx
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
def build_ex_index(m):
|
|
135
|
-
"""EX 交换名索引(去 -e0 后缀小写)-> EX 反应 id。
|
|
136
|
-
两级策略(阶段B):
|
|
137
|
-
① EX_/DM_/SK_ 前缀优先——现有行为逐字保留(仅 EX_ 前缀反应入索引);
|
|
138
|
-
② 仅当模型 EX_ 索引整体为空(全模型无 EX_ 前缀交换反应)时,回退用 boundary
|
|
139
|
-
单代谢物反应(恰好 1 个代谢物且 rxn.boundary=True)作为交换候选,返回
|
|
140
|
-
BoundaryExIndex(真实反应 id 为值;禁用受控子串层防跨命名空间误配)。
|
|
141
|
-
"单个名字未命中"不触发回退——有 EX_ 层的模型永不启用②(C58 零影响硬保证)。"""
|
|
142
|
-
idx = {}
|
|
143
|
-
for r in m.reactions:
|
|
144
|
-
if r.id.startswith("EX_"):
|
|
145
|
-
for x in r.metabolites:
|
|
146
|
-
nm = (x.name or "").strip().lower()
|
|
147
|
-
if nm.endswith("-e0"):
|
|
148
|
-
nm = nm[:-3]
|
|
149
|
-
if nm:
|
|
150
|
-
idx.setdefault(norm(nm), r.id)
|
|
151
|
-
if idx:
|
|
152
|
-
return idx
|
|
153
|
-
# 阶段B 两级策略②:EX_ 层整体缺失 -> boundary 单代谢物反应回退
|
|
154
|
-
bidx = BoundaryExIndex()
|
|
155
|
-
for r in m.reactions:
|
|
156
|
-
if r.boundary and len(r.metabolites) == 1:
|
|
157
|
-
x = next(iter(r.metabolites))
|
|
158
|
-
nm = (x.name or "").strip().lower()
|
|
159
|
-
if nm:
|
|
160
|
-
bidx.setdefault(norm(nm), r.id)
|
|
161
|
-
bidx.setdefault(norm("EX_" + x.id), r.id) # 伪 EX 约定键(EX_M00081_e 形式可直传)
|
|
162
|
-
bidx.setdefault(norm(r.id), r.id) # 真实反应 id 直键
|
|
163
|
-
return bidx
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
def match_ex(sub, ex_idx, allow_substring=None):
|
|
167
|
-
"""底物名 -> EX id;三层:精确(含 SYN 别名) -> CARVE_ALIAS -> 受控子串回退。
|
|
168
|
-
子串回退防误伤(2026-08-29 实测):'o2' 曾命中 'R Acetoin C4H8O2'(名字尾部含 o2)
|
|
169
|
-
导致 O2 交换错配 -> 模型"AB 不生长"假象。规则:
|
|
170
|
-
- 短 key(<=4 且不含 '+'):只允许前缀匹配(n.startswith(key))
|
|
171
|
-
- 含 '+' 的 key(金属离子):允许前缀或后缀(iron(fe3+)→ironfe3+ endswith fe3+)
|
|
172
|
-
- 长 key(>=5):允许子串
|
|
173
|
-
阶段B:受控子串层可对单个索引禁用——boundary 回退索引(BoundaryExIndex)跨命名空间
|
|
174
|
-
短名易误配(实测 'd-glucose' ⊂ 'd-glucose1-phosphate' 会把 G1P 交换错当葡萄糖),
|
|
175
|
-
故其 allow_substring=False;普通 dict 索引默认 True,既有行为逐字保留。"""
|
|
176
|
-
key = norm(sub)
|
|
177
|
-
if key in ex_idx:
|
|
178
|
-
return ex_idx[key]
|
|
179
|
-
s = SYN.get((sub or "").strip().lower())
|
|
180
|
-
if s and norm(s) in ex_idx:
|
|
181
|
-
return ex_idx[norm(s)]
|
|
182
|
-
if allow_substring is None:
|
|
183
|
-
allow_substring = getattr(ex_idx, "allow_substring", True)
|
|
184
|
-
a = CARVE_ALIAS.get(key)
|
|
185
|
-
cands = a if isinstance(a, list) else [a]
|
|
186
|
-
for a in cands:
|
|
187
|
-
if not a:
|
|
188
|
-
continue
|
|
189
|
-
if a in ex_idx:
|
|
190
|
-
return ex_idx[a]
|
|
191
|
-
if not allow_substring:
|
|
192
|
-
continue # boundary 回退索引:alias 展开的模糊匹配同样禁用(防 G1P 冒充葡萄糖类误配)
|
|
193
|
-
for n, rid in ex_idx.items():
|
|
194
|
-
if n.startswith(a) or (("+" in a or len(a) >= 5) and a in n):
|
|
195
|
-
return rid
|
|
196
|
-
if allow_substring is None:
|
|
197
|
-
allow_substring = getattr(ex_idx, "allow_substring", True)
|
|
198
|
-
if not allow_substring:
|
|
199
|
-
return None
|
|
200
|
-
for n, rid in ex_idx.items():
|
|
201
|
-
# 只做正向子串(key 是 n 的子串);反向(n in key)误伤严重:
|
|
202
|
-
# "no" in "arabinose"、"co" in "gluconate"、"phosphate" in "glucose-1-phosphate"
|
|
203
|
-
if key in n:
|
|
204
|
-
if n.startswith(key):
|
|
205
|
-
return rid
|
|
206
|
-
if "+" in key or len(key) >= 5:
|
|
207
|
-
return rid
|
|
208
|
-
return None
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
class BoundaryExIndex(dict):
|
|
212
|
-
"""两级策略②的 boundary 回退索引(阶段B)。
|
|
213
|
-
仅当全模型无 EX_ 前缀交换反应时由 build_ex_index 产出;禁用受控子串回退层
|
|
214
|
-
(跨命名空间误配实证:d-glucose ⊂ d-glucose1-phosphate)。"""
|
|
215
|
-
allow_substring = False
|
|
216
|
-
boundary_style = True
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
def ex_index_is_boundary(ex_idx):
|
|
220
|
-
"""该索引是否为 boundary 回退型(boundary_style=True 的模型介质层)。"""
|
|
221
|
-
return isinstance(ex_idx, BoundaryExIndex)
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
def has_ex_layer(m):
|
|
225
|
-
"""模型是否有 EX_ 前缀交换层(决定是否启用 boundary 回退)。"""
|
|
226
|
-
return any(r.id.startswith("EX_") for r in m.reactions)
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
def ex_display_name(m, rid):
|
|
230
|
-
"""交换的规范展示名:boundary 型交换 -> EX_<met_id>(boundary-derived);EX_ 型返回原 id。"""
|
|
231
|
-
try:
|
|
232
|
-
rxn = m.reactions.get_by_id(rid)
|
|
233
|
-
except Exception:
|
|
234
|
-
return rid
|
|
235
|
-
mets = list(rxn.metabolites)
|
|
236
|
-
if (not rid.startswith("EX_")) and len(mets) == 1:
|
|
237
|
-
return f"EX_{mets[0].id}(boundary-derived)"
|
|
238
|
-
return rid
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
def resolve_medium(m, medium):
|
|
242
|
-
"""介质字典 -> {EX_id: lb}。键可为 EX ID(直接用)或自然名/别名(跨引擎匹配)。
|
|
243
|
-
返回 (resolved, unresolved_names)。"""
|
|
244
|
-
ex_idx = build_ex_index(m)
|
|
245
|
-
resolved, unresolved = {}, []
|
|
246
|
-
for k, lb in (medium or {}).items():
|
|
247
|
-
if k.startswith("EX_") and k in m.reactions:
|
|
248
|
-
resolved[k] = lb
|
|
249
|
-
continue
|
|
250
|
-
exid = match_ex(k, ex_idx)
|
|
251
|
-
if exid:
|
|
252
|
-
resolved[exid] = lb
|
|
253
|
-
else:
|
|
254
|
-
unresolved.append(k)
|
|
255
|
-
return resolved, unresolved
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
def _met_has_outlets(m, met_id):
|
|
259
|
-
"""代谢物 e0 是否有非 EX 反应消耗(转运面)。"""
|
|
260
|
-
met = m.metabolites.get_by_id(met_id)
|
|
261
|
-
outlets = [r.id for r in met.reactions if not r.id.startswith(EX_PREFIX) and not r.boundary]
|
|
262
|
-
return outlets
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
def _growth_with(m, medium, extra_ex, lb=-10.0):
|
|
266
|
-
"""基底 medium + 额外交换 extra_ex G3 式判定;返回生长值。"""
|
|
267
|
-
with m:
|
|
268
|
-
for r in m.reactions:
|
|
269
|
-
if r.id.startswith(EX_PREFIX) or r.boundary:
|
|
270
|
-
r.lower_bound = 0.0
|
|
271
|
-
for rid, v in (medium or {}).items():
|
|
272
|
-
if rid in m.reactions:
|
|
273
|
-
m.reactions.get_by_id(rid).lower_bound = v
|
|
274
|
-
if extra_ex and extra_ex in m.reactions:
|
|
275
|
-
m.reactions.get_by_id(extra_ex).lower_bound = lb
|
|
276
|
-
return m.optimize().objective_value
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
def _growth_with_solo(m, medium, extra_ex, lb=-10.0):
|
|
280
|
-
"""严格语义:基底 medium 去掉含碳交换 + 额外交换(唯一碳源防 AB 背景掩盖)。
|
|
281
|
-
含碳判断用元素解析(parse_formula)——裸子串 'C' in formula 会把 Ca/Cl/Co/Cu 误滤
|
|
282
|
-
(2026-08-29 实测:氯被误滤后 sole 葡萄糖生长=0,造成 L3 假阳性)。"""
|
|
283
|
-
from silentio import silent_read_sbml
|
|
284
|
-
from validate import parse_formula
|
|
285
|
-
with m:
|
|
286
|
-
for r in m.reactions:
|
|
287
|
-
if r.id.startswith(EX_PREFIX) or r.boundary:
|
|
288
|
-
r.lower_bound = 0.0
|
|
289
|
-
for rid, v in (medium or {}).items():
|
|
290
|
-
if rid in m.reactions:
|
|
291
|
-
met = list(m.reactions.get_by_id(rid).metabolites)[0]
|
|
292
|
-
if met.formula and "C" in parse_formula(met.formula):
|
|
293
|
-
continue # 去碳
|
|
294
|
-
m.reactions.get_by_id(rid).lower_bound = v
|
|
295
|
-
if extra_ex and extra_ex in m.reactions:
|
|
296
|
-
m.reactions.get_by_id(extra_ex).lower_bound = lb
|
|
297
|
-
return m.optimize().objective_value
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
def find_gaps(model_path, medium=None, substrates=None):
|
|
301
|
-
from silentio import silent_read_sbml
|
|
302
|
-
m = silent_read_sbml(model_path)
|
|
303
|
-
ex_idx = build_ex_index(m)
|
|
304
|
-
met_idx = build_met_index(m)
|
|
305
|
-
L1, L2, L3 = [], [], []
|
|
306
|
-
|
|
307
|
-
# medium 预设展开(支持 {"medium_name": "AB"|"M9"})+ 跨引擎介质归一化
|
|
308
|
-
medium, preset_name = expand_medium(medium)
|
|
309
|
-
resolved_med, unresolved_names = resolve_medium(m, medium)
|
|
310
|
-
|
|
311
|
-
# ---- L1: 缺交换 ----
|
|
312
|
-
# (a) 用户显式声明的 EX ID 模型没有
|
|
313
|
-
for rid, lb in (medium or {}).items():
|
|
314
|
-
if rid.startswith("EX_") and rid not in m.reactions:
|
|
315
|
-
L1.append({
|
|
316
|
-
"type": "exchange_missing",
|
|
317
|
-
"exchange": rid, "medium_lb": lb,
|
|
318
|
-
"fixable": "yes" if _mids_exist(m, rid) else "no",
|
|
319
|
-
})
|
|
320
|
-
# (a2) 自然名匹配不到任何交换
|
|
321
|
-
for nm in unresolved_names:
|
|
322
|
-
L1.append({
|
|
323
|
-
"type": "exchange_unresolved_name", "substrate": nm,
|
|
324
|
-
"fixable": "yes" if _c0_exists(m, nm, met_idx) else "no",
|
|
325
|
-
})
|
|
326
|
-
# (b) 底物名匹配不到 EX(模型无对应交换)
|
|
327
|
-
for sub in (substrates or []):
|
|
328
|
-
exid = match_ex(sub, ex_idx)
|
|
329
|
-
if not exid:
|
|
330
|
-
L1.append({
|
|
331
|
-
"type": "exchange_missing_name", "substrate": sub,
|
|
332
|
-
"fixable": "yes" if _c0_exists(m, sub, met_idx) else "no",
|
|
333
|
-
})
|
|
334
|
-
# ---- L2: 缺转运(e0 代谢物无非 EX 出口)----
|
|
335
|
-
cand_e0 = set()
|
|
336
|
-
for rid, lb in resolved_med.items():
|
|
337
|
-
if rid.startswith("EX_") and rid in m.reactions:
|
|
338
|
-
for x in m.reactions.get_by_id(rid).metabolites:
|
|
339
|
-
if x.compartment == "e0":
|
|
340
|
-
cand_e0.add(x.id)
|
|
341
|
-
for sub in (substrates or []):
|
|
342
|
-
exid = match_ex(sub, ex_idx)
|
|
343
|
-
if exid and exid in m.reactions:
|
|
344
|
-
for x in m.reactions.get_by_id(exid).metabolites:
|
|
345
|
-
if x.compartment == "e0":
|
|
346
|
-
cand_e0.add(x.id)
|
|
347
|
-
for mid in sorted(cand_e0):
|
|
348
|
-
outlets = _met_has_outlets(m, mid)
|
|
349
|
-
if not outlets:
|
|
350
|
-
c0 = mid[:-3] + "c0" if mid.endswith("_e0") else None
|
|
351
|
-
L2.append({
|
|
352
|
-
"type": "transport_missing", "metabolite_e0": mid,
|
|
353
|
-
"metabolite_c0": c0 if c0 and c0 in m.metabolites else None,
|
|
354
|
-
"fixable": "yes" if (c0 and c0 in m.metabolites) else "no",
|
|
355
|
-
})
|
|
356
|
-
|
|
357
|
-
# ---- L3: 内部路径(有交换+转运但 FBA 不长;严格语义=唯一碳源防 AB 背景掩盖)----
|
|
358
|
-
for sub in (substrates or []):
|
|
359
|
-
exid = match_ex(sub, ex_idx)
|
|
360
|
-
if not exid or exid not in m.reactions:
|
|
361
|
-
continue # L1 已报
|
|
362
|
-
g = _growth_with_solo(m, resolved_med, exid)
|
|
363
|
-
if g < 1e-6:
|
|
364
|
-
L3.append({"type": "internal_path", "substrate": sub,
|
|
365
|
-
"exchange": exid, "growth": round(g, 6),
|
|
366
|
-
"note": "需要文献反应或人工审核(M1 不自动补)"})
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
if
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
1
|
+
# gapfind.py — dsh-bio-gem 缺口分级诊断(L1 缺交换 / L2 缺转运 / L3 内部路径)
|
|
2
|
+
# 规格: docs/ARCHITECTURE.md §6;已知规律(P1 实测):多数"不能利用某碳源"缺口是 L1/L2 而非 L3。
|
|
3
|
+
# 用法: find_gaps(model, medium=None, substrates=None) -> {"L1": [...], "L2": [...], "L3": [...]}
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import sys
|
|
7
|
+
import cobra
|
|
8
|
+
|
|
9
|
+
# Python -I isolated 模式下脚本目录不进 sys.path——显式插入以导入同目录模块
|
|
10
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
11
|
+
|
|
12
|
+
EX_PREFIX = ("EX_", "DM_", "SK_")
|
|
13
|
+
|
|
14
|
+
# 培养基常见成分别名(自然名 -> 模型代谢物名,去 -e0 后缀的小写形式)
|
|
15
|
+
SYN = {
|
|
16
|
+
"orthophosphate": "phosphate", "oxygen": "o2", "l-ornithine": "ornithine",
|
|
17
|
+
"d-galactose": "galactose", "d-xylose": "xylose", "malate": "l-malate",
|
|
18
|
+
"mannose": "d-mannose", "ribose": "d-ribose", "mn": "mn2+",
|
|
19
|
+
"ca": "ca2+", "cl": "cl-", "k": "k+", "mg": "mg",
|
|
20
|
+
"sodium cation": "na+", "fe3+": "fe3", "fe2+": "fe2+",
|
|
21
|
+
"copper": "cu2+", "zinc cation": "zn2+", "biotin": "biot",
|
|
22
|
+
"thiamine": "thiamin", "raffinose": "trhl", "alpha,alpha-trehalose": "trhl",
|
|
23
|
+
"trehalose": "trhl", "4-aminobutanoate": "gaba", "akg": "2-oxoglutarate",
|
|
24
|
+
"fumarate": "fumarate", "beta-d-glucose": "d-glucose", "urea": "urea",
|
|
25
|
+
"galactonate": "d-galactonate", "lactose": "beta-lactose",
|
|
26
|
+
"mantiol": "d-mannitol", "maltotriose": "amylotriose",
|
|
27
|
+
"sn-glycerol 3-phosphate": "glycerol-3-phosphate",
|
|
28
|
+
"malic acid": "l-malate", "gluconate": "d-gluconate",
|
|
29
|
+
"glucose 1-phosphate": "glucose-1-phosphate", "glumate": "l-glutamate",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
# CarveMe/BiGG 风格别名(跨引擎:gapseq 缩写 -> BiGG 全名;用于 resolve_medium)
|
|
33
|
+
CARVE_ALIAS = {
|
|
34
|
+
"ca2+": "calcium", "k+": "potassium", "mg2+": "magnesium", "na+": "sodium",
|
|
35
|
+
"mn2+": "manganese", "zn2+": "zinc", "ni2+": "nickel", "cu2+": "copper",
|
|
36
|
+
"cl-": "chloride", "nh3": "ammonium", "nh4+": "ammonium", "ammonium": "ammonium",
|
|
37
|
+
"o2": "o2", "h2o": "h2o", "co2": "co2", "cobalt": "co2+", "co2+": "co2+",
|
|
38
|
+
"fe2+": "fe2+", "fe3+": "fe3+", "h+": "h+", "h": "h+",
|
|
39
|
+
"d-glucose": "d-glucose", "phosphate": "phosphate", "sulfate": "sulfate",
|
|
40
|
+
"glucose": "d-glucose", "ammonia": "ammonium",
|
|
41
|
+
# gapseq 命名(无 + 后缀)与 CarveMe 全名双候选
|
|
42
|
+
"mg": ["mg", "magnesium"], "mg2+": ["magnesium", "mg"],
|
|
43
|
+
"mn": ["mn2+", "manganese"], "zn": ["zn2+", "zinc"], "ca": ["ca2+", "calcium"],
|
|
44
|
+
"k": ["k+", "potassium"], "na": ["na+", "sodium"], "cl": ["cl-", "chloride"],
|
|
45
|
+
"fe": ["fe2+", "iron"],
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
# 常用介质预设(自然名成分 -> lb)。agent 只需传 {"medium_name": "AB"} 即可
|
|
49
|
+
# 获得完整成分(金属离子绝不能省——gapseq 生物质方程直接消耗,缺金属生长恒 0)。
|
|
50
|
+
MEDIA_PRESETS = {
|
|
51
|
+
"AB": {
|
|
52
|
+
"D-Glucose": -5, "NH3": -10, "O2": -12.5, "CO2": -15, "H+": -20, "H2O": -100,
|
|
53
|
+
"Phosphate": -10, "Sulfate": -10, "Cl-": -10, "Mn2+": -10, "Zn2+": -10,
|
|
54
|
+
"Co2+": -10, "Ni2+": -1, "Fe3+": -0.1, "Fe2+": -10, "Ca2+": -10, "Cu2+": -10,
|
|
55
|
+
"K+": -10, "Mg2+": -10, "Na+": -10,
|
|
56
|
+
},
|
|
57
|
+
"M9": {}, # 动态:由 _m9_preset() 从 carveme media_db 提取(BiGG ID 形式)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _m9_preset():
|
|
62
|
+
"""M9 预设:从 carveme media_db.tsv 提取(BiGG compound 名 -> EX_<c>_e,lb -10)。
|
|
63
|
+
找不到 carveme 时回退常用 M9 成分(BiGG ID 静态表)。"""
|
|
64
|
+
import csv
|
|
65
|
+
home = os.path.expanduser("~")
|
|
66
|
+
db = os.path.join(home, ".dsh", "dsh-bio-gem", "venv-carveme", "Lib",
|
|
67
|
+
"site-packages", "carveme", "data", "input", "media_db.tsv")
|
|
68
|
+
comps = set()
|
|
69
|
+
if os.path.exists(db):
|
|
70
|
+
with open(db, encoding="utf-8") as f:
|
|
71
|
+
rd = csv.DictReader(f, delimiter="\t")
|
|
72
|
+
for row in rd:
|
|
73
|
+
if row.get("medium") == "M9" and row.get("compound"):
|
|
74
|
+
comps.add(row["compound"].strip())
|
|
75
|
+
if comps:
|
|
76
|
+
return {"EX_" + c + "_e": -10.0 for c in sorted(comps)}
|
|
77
|
+
return {"EX_glc__D_e": -10.0, "EX_nh4_e": -10.0, "EX_o2_e": -12.5,
|
|
78
|
+
"EX_pi_e": -10.0, "EX_so4_e": -10.0, "EX_k_e": -10.0,
|
|
79
|
+
"EX_mg2_e": -10.0, "EX_ca2_e": -10.0, "EX_fe2_e": -10.0,
|
|
80
|
+
"EX_fe3_e": -0.1, "EX_mn2_e": -10.0, "EX_zn2_e": -10.0,
|
|
81
|
+
"EX_cobalt2_e": -10.0, "EX_ni2_e": -10.0, "EX_cu2_e": -10.0,
|
|
82
|
+
"EX_cl_e": -10.0, "EX_na1_e": -10.0, "EX_h2o_e": -100.0,
|
|
83
|
+
"EX_h_e": -20.0, "EX_co2_e": -15.0}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def expand_medium(medium):
|
|
87
|
+
"""medium 展开:支持 {"medium_name": "AB", ...覆盖成分};M9 动态提取。
|
|
88
|
+
返回 (merged_dict, preset_name_or_None)。"""
|
|
89
|
+
if not medium:
|
|
90
|
+
return medium or {}, None
|
|
91
|
+
med = dict(medium)
|
|
92
|
+
name = med.pop("medium_name", None)
|
|
93
|
+
merged = {}
|
|
94
|
+
if name:
|
|
95
|
+
if name in MEDIA_PRESETS:
|
|
96
|
+
preset = MEDIA_PRESETS[name]
|
|
97
|
+
if name == "M9" and not preset:
|
|
98
|
+
preset = _m9_preset()
|
|
99
|
+
merged.update(preset)
|
|
100
|
+
else:
|
|
101
|
+
pass # 未知预设:保留用户成分,调用方可记 unresolved
|
|
102
|
+
merged.update(med)
|
|
103
|
+
return merged, name
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# 代谢物 c0 短名别名(自然名/常用名 -> 模型胞内代谢物名;用于 L1 名字补洞)
|
|
107
|
+
# 实测:gapseq 用 BiGG 短名(D-Gluconate-c0 名存为 'GLCN-c0'),SYN 只管 EX 名映射
|
|
108
|
+
MET_ALIAS = {
|
|
109
|
+
"gluconate": "glcn", "d-gluconate": "glcn", "6-phospho-d-gluconate": "6pgc",
|
|
110
|
+
"sucrose": "sucrose", "glucose-1-phosphate": "glucose-1-phosphate",
|
|
111
|
+
"d-glucose-1-phosphate": "glucose-1-phosphate", "g1p": "glucose-1-phosphate",
|
|
112
|
+
"d-glucose": "d-glucose", "glucose": "d-glucose",
|
|
113
|
+
"l-malate": "mal__l", "malate": "mal__l", "malic acid": "mal__l",
|
|
114
|
+
"citrate": "cit", "succinate": "succ", "d-ribose": "rib__d", "ribose": "rib__d",
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
def norm(s):
|
|
118
|
+
return "".join(ch for ch in (s or "").strip().lower() if ch.isalnum() or ch in "+-")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def build_met_index(m, compartment="c0"):
|
|
122
|
+
"""胞内代谢物名索引(去 -c0 后缀小写)-> met id。"""
|
|
123
|
+
idx = {}
|
|
124
|
+
for x in m.metabolites:
|
|
125
|
+
if x.compartment == compartment:
|
|
126
|
+
nm = (x.name or "").strip().lower()
|
|
127
|
+
if nm.endswith("-c0"):
|
|
128
|
+
nm = nm[:-3]
|
|
129
|
+
if nm:
|
|
130
|
+
idx.setdefault(norm(nm), x.id)
|
|
131
|
+
return idx
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def build_ex_index(m):
|
|
135
|
+
"""EX 交换名索引(去 -e0 后缀小写)-> EX 反应 id。
|
|
136
|
+
两级策略(阶段B):
|
|
137
|
+
① EX_/DM_/SK_ 前缀优先——现有行为逐字保留(仅 EX_ 前缀反应入索引);
|
|
138
|
+
② 仅当模型 EX_ 索引整体为空(全模型无 EX_ 前缀交换反应)时,回退用 boundary
|
|
139
|
+
单代谢物反应(恰好 1 个代谢物且 rxn.boundary=True)作为交换候选,返回
|
|
140
|
+
BoundaryExIndex(真实反应 id 为值;禁用受控子串层防跨命名空间误配)。
|
|
141
|
+
"单个名字未命中"不触发回退——有 EX_ 层的模型永不启用②(C58 零影响硬保证)。"""
|
|
142
|
+
idx = {}
|
|
143
|
+
for r in m.reactions:
|
|
144
|
+
if r.id.startswith("EX_"):
|
|
145
|
+
for x in r.metabolites:
|
|
146
|
+
nm = (x.name or "").strip().lower()
|
|
147
|
+
if nm.endswith("-e0"):
|
|
148
|
+
nm = nm[:-3]
|
|
149
|
+
if nm:
|
|
150
|
+
idx.setdefault(norm(nm), r.id)
|
|
151
|
+
if idx:
|
|
152
|
+
return idx
|
|
153
|
+
# 阶段B 两级策略②:EX_ 层整体缺失 -> boundary 单代谢物反应回退
|
|
154
|
+
bidx = BoundaryExIndex()
|
|
155
|
+
for r in m.reactions:
|
|
156
|
+
if r.boundary and len(r.metabolites) == 1:
|
|
157
|
+
x = next(iter(r.metabolites))
|
|
158
|
+
nm = (x.name or "").strip().lower()
|
|
159
|
+
if nm:
|
|
160
|
+
bidx.setdefault(norm(nm), r.id)
|
|
161
|
+
bidx.setdefault(norm("EX_" + x.id), r.id) # 伪 EX 约定键(EX_M00081_e 形式可直传)
|
|
162
|
+
bidx.setdefault(norm(r.id), r.id) # 真实反应 id 直键
|
|
163
|
+
return bidx
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def match_ex(sub, ex_idx, allow_substring=None):
|
|
167
|
+
"""底物名 -> EX id;三层:精确(含 SYN 别名) -> CARVE_ALIAS -> 受控子串回退。
|
|
168
|
+
子串回退防误伤(2026-08-29 实测):'o2' 曾命中 'R Acetoin C4H8O2'(名字尾部含 o2)
|
|
169
|
+
导致 O2 交换错配 -> 模型"AB 不生长"假象。规则:
|
|
170
|
+
- 短 key(<=4 且不含 '+'):只允许前缀匹配(n.startswith(key))
|
|
171
|
+
- 含 '+' 的 key(金属离子):允许前缀或后缀(iron(fe3+)→ironfe3+ endswith fe3+)
|
|
172
|
+
- 长 key(>=5):允许子串
|
|
173
|
+
阶段B:受控子串层可对单个索引禁用——boundary 回退索引(BoundaryExIndex)跨命名空间
|
|
174
|
+
短名易误配(实测 'd-glucose' ⊂ 'd-glucose1-phosphate' 会把 G1P 交换错当葡萄糖),
|
|
175
|
+
故其 allow_substring=False;普通 dict 索引默认 True,既有行为逐字保留。"""
|
|
176
|
+
key = norm(sub)
|
|
177
|
+
if key in ex_idx:
|
|
178
|
+
return ex_idx[key]
|
|
179
|
+
s = SYN.get((sub or "").strip().lower())
|
|
180
|
+
if s and norm(s) in ex_idx:
|
|
181
|
+
return ex_idx[norm(s)]
|
|
182
|
+
if allow_substring is None:
|
|
183
|
+
allow_substring = getattr(ex_idx, "allow_substring", True)
|
|
184
|
+
a = CARVE_ALIAS.get(key)
|
|
185
|
+
cands = a if isinstance(a, list) else [a]
|
|
186
|
+
for a in cands:
|
|
187
|
+
if not a:
|
|
188
|
+
continue
|
|
189
|
+
if a in ex_idx:
|
|
190
|
+
return ex_idx[a]
|
|
191
|
+
if not allow_substring:
|
|
192
|
+
continue # boundary 回退索引:alias 展开的模糊匹配同样禁用(防 G1P 冒充葡萄糖类误配)
|
|
193
|
+
for n, rid in ex_idx.items():
|
|
194
|
+
if n.startswith(a) or (("+" in a or len(a) >= 5) and a in n):
|
|
195
|
+
return rid
|
|
196
|
+
if allow_substring is None:
|
|
197
|
+
allow_substring = getattr(ex_idx, "allow_substring", True)
|
|
198
|
+
if not allow_substring:
|
|
199
|
+
return None
|
|
200
|
+
for n, rid in ex_idx.items():
|
|
201
|
+
# 只做正向子串(key 是 n 的子串);反向(n in key)误伤严重:
|
|
202
|
+
# "no" in "arabinose"、"co" in "gluconate"、"phosphate" in "glucose-1-phosphate"
|
|
203
|
+
if key in n:
|
|
204
|
+
if n.startswith(key):
|
|
205
|
+
return rid
|
|
206
|
+
if "+" in key or len(key) >= 5:
|
|
207
|
+
return rid
|
|
208
|
+
return None
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
class BoundaryExIndex(dict):
|
|
212
|
+
"""两级策略②的 boundary 回退索引(阶段B)。
|
|
213
|
+
仅当全模型无 EX_ 前缀交换反应时由 build_ex_index 产出;禁用受控子串回退层
|
|
214
|
+
(跨命名空间误配实证:d-glucose ⊂ d-glucose1-phosphate)。"""
|
|
215
|
+
allow_substring = False
|
|
216
|
+
boundary_style = True
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def ex_index_is_boundary(ex_idx):
|
|
220
|
+
"""该索引是否为 boundary 回退型(boundary_style=True 的模型介质层)。"""
|
|
221
|
+
return isinstance(ex_idx, BoundaryExIndex)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def has_ex_layer(m):
|
|
225
|
+
"""模型是否有 EX_ 前缀交换层(决定是否启用 boundary 回退)。"""
|
|
226
|
+
return any(r.id.startswith("EX_") for r in m.reactions)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def ex_display_name(m, rid):
|
|
230
|
+
"""交换的规范展示名:boundary 型交换 -> EX_<met_id>(boundary-derived);EX_ 型返回原 id。"""
|
|
231
|
+
try:
|
|
232
|
+
rxn = m.reactions.get_by_id(rid)
|
|
233
|
+
except Exception:
|
|
234
|
+
return rid
|
|
235
|
+
mets = list(rxn.metabolites)
|
|
236
|
+
if (not rid.startswith("EX_")) and len(mets) == 1:
|
|
237
|
+
return f"EX_{mets[0].id}(boundary-derived)"
|
|
238
|
+
return rid
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def resolve_medium(m, medium):
|
|
242
|
+
"""介质字典 -> {EX_id: lb}。键可为 EX ID(直接用)或自然名/别名(跨引擎匹配)。
|
|
243
|
+
返回 (resolved, unresolved_names)。"""
|
|
244
|
+
ex_idx = build_ex_index(m)
|
|
245
|
+
resolved, unresolved = {}, []
|
|
246
|
+
for k, lb in (medium or {}).items():
|
|
247
|
+
if k.startswith("EX_") and k in m.reactions:
|
|
248
|
+
resolved[k] = lb
|
|
249
|
+
continue
|
|
250
|
+
exid = match_ex(k, ex_idx)
|
|
251
|
+
if exid:
|
|
252
|
+
resolved[exid] = lb
|
|
253
|
+
else:
|
|
254
|
+
unresolved.append(k)
|
|
255
|
+
return resolved, unresolved
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _met_has_outlets(m, met_id):
|
|
259
|
+
"""代谢物 e0 是否有非 EX 反应消耗(转运面)。"""
|
|
260
|
+
met = m.metabolites.get_by_id(met_id)
|
|
261
|
+
outlets = [r.id for r in met.reactions if not r.id.startswith(EX_PREFIX) and not r.boundary]
|
|
262
|
+
return outlets
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _growth_with(m, medium, extra_ex, lb=-10.0):
|
|
266
|
+
"""基底 medium + 额外交换 extra_ex G3 式判定;返回生长值。"""
|
|
267
|
+
with m:
|
|
268
|
+
for r in m.reactions:
|
|
269
|
+
if r.id.startswith(EX_PREFIX) or r.boundary:
|
|
270
|
+
r.lower_bound = 0.0
|
|
271
|
+
for rid, v in (medium or {}).items():
|
|
272
|
+
if rid in m.reactions:
|
|
273
|
+
m.reactions.get_by_id(rid).lower_bound = v
|
|
274
|
+
if extra_ex and extra_ex in m.reactions:
|
|
275
|
+
m.reactions.get_by_id(extra_ex).lower_bound = lb
|
|
276
|
+
return m.optimize().objective_value
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _growth_with_solo(m, medium, extra_ex, lb=-10.0):
|
|
280
|
+
"""严格语义:基底 medium 去掉含碳交换 + 额外交换(唯一碳源防 AB 背景掩盖)。
|
|
281
|
+
含碳判断用元素解析(parse_formula)——裸子串 'C' in formula 会把 Ca/Cl/Co/Cu 误滤
|
|
282
|
+
(2026-08-29 实测:氯被误滤后 sole 葡萄糖生长=0,造成 L3 假阳性)。"""
|
|
283
|
+
from silentio import silent_read_sbml
|
|
284
|
+
from validate import parse_formula
|
|
285
|
+
with m:
|
|
286
|
+
for r in m.reactions:
|
|
287
|
+
if r.id.startswith(EX_PREFIX) or r.boundary:
|
|
288
|
+
r.lower_bound = 0.0
|
|
289
|
+
for rid, v in (medium or {}).items():
|
|
290
|
+
if rid in m.reactions:
|
|
291
|
+
met = list(m.reactions.get_by_id(rid).metabolites)[0]
|
|
292
|
+
if met.formula and "C" in parse_formula(met.formula):
|
|
293
|
+
continue # 去碳
|
|
294
|
+
m.reactions.get_by_id(rid).lower_bound = v
|
|
295
|
+
if extra_ex and extra_ex in m.reactions:
|
|
296
|
+
m.reactions.get_by_id(extra_ex).lower_bound = lb
|
|
297
|
+
return m.optimize().objective_value
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def find_gaps(model_path, medium=None, substrates=None):
|
|
301
|
+
from silentio import silent_read_sbml
|
|
302
|
+
m = silent_read_sbml(model_path)
|
|
303
|
+
ex_idx = build_ex_index(m)
|
|
304
|
+
met_idx = build_met_index(m)
|
|
305
|
+
L1, L2, L3 = [], [], []
|
|
306
|
+
|
|
307
|
+
# medium 预设展开(支持 {"medium_name": "AB"|"M9"})+ 跨引擎介质归一化
|
|
308
|
+
medium, preset_name = expand_medium(medium)
|
|
309
|
+
resolved_med, unresolved_names = resolve_medium(m, medium)
|
|
310
|
+
|
|
311
|
+
# ---- L1: 缺交换 ----
|
|
312
|
+
# (a) 用户显式声明的 EX ID 模型没有
|
|
313
|
+
for rid, lb in (medium or {}).items():
|
|
314
|
+
if rid.startswith("EX_") and rid not in m.reactions:
|
|
315
|
+
L1.append({
|
|
316
|
+
"type": "exchange_missing",
|
|
317
|
+
"exchange": rid, "medium_lb": lb,
|
|
318
|
+
"fixable": "yes" if _mids_exist(m, rid) else "no",
|
|
319
|
+
})
|
|
320
|
+
# (a2) 自然名匹配不到任何交换
|
|
321
|
+
for nm in unresolved_names:
|
|
322
|
+
L1.append({
|
|
323
|
+
"type": "exchange_unresolved_name", "substrate": nm,
|
|
324
|
+
"fixable": "yes" if _c0_exists(m, nm, met_idx) else "no",
|
|
325
|
+
})
|
|
326
|
+
# (b) 底物名匹配不到 EX(模型无对应交换)
|
|
327
|
+
for sub in (substrates or []):
|
|
328
|
+
exid = match_ex(sub, ex_idx)
|
|
329
|
+
if not exid:
|
|
330
|
+
L1.append({
|
|
331
|
+
"type": "exchange_missing_name", "substrate": sub,
|
|
332
|
+
"fixable": "yes" if _c0_exists(m, sub, met_idx) else "no",
|
|
333
|
+
})
|
|
334
|
+
# ---- L2: 缺转运(e0 代谢物无非 EX 出口)----
|
|
335
|
+
cand_e0 = set()
|
|
336
|
+
for rid, lb in resolved_med.items():
|
|
337
|
+
if rid.startswith("EX_") and rid in m.reactions:
|
|
338
|
+
for x in m.reactions.get_by_id(rid).metabolites:
|
|
339
|
+
if x.compartment == "e0":
|
|
340
|
+
cand_e0.add(x.id)
|
|
341
|
+
for sub in (substrates or []):
|
|
342
|
+
exid = match_ex(sub, ex_idx)
|
|
343
|
+
if exid and exid in m.reactions:
|
|
344
|
+
for x in m.reactions.get_by_id(exid).metabolites:
|
|
345
|
+
if x.compartment == "e0":
|
|
346
|
+
cand_e0.add(x.id)
|
|
347
|
+
for mid in sorted(cand_e0):
|
|
348
|
+
outlets = _met_has_outlets(m, mid)
|
|
349
|
+
if not outlets:
|
|
350
|
+
c0 = mid[:-3] + "c0" if mid.endswith("_e0") else None
|
|
351
|
+
L2.append({
|
|
352
|
+
"type": "transport_missing", "metabolite_e0": mid,
|
|
353
|
+
"metabolite_c0": c0 if c0 and c0 in m.metabolites else None,
|
|
354
|
+
"fixable": "yes" if (c0 and c0 in m.metabolites) else "no",
|
|
355
|
+
})
|
|
356
|
+
|
|
357
|
+
# ---- L3: 内部路径(有交换+转运但 FBA 不长;严格语义=唯一碳源防 AB 背景掩盖)----
|
|
358
|
+
for sub in (substrates or []):
|
|
359
|
+
exid = match_ex(sub, ex_idx)
|
|
360
|
+
if not exid or exid not in m.reactions:
|
|
361
|
+
continue # L1 已报
|
|
362
|
+
g = _growth_with_solo(m, resolved_med, exid)
|
|
363
|
+
if g < 1e-6:
|
|
364
|
+
L3.append({"type": "internal_path", "substrate": sub,
|
|
365
|
+
"exchange": exid, "growth": round(g, 6),
|
|
366
|
+
"note": "需要文献反应或人工审核(M1 不自动补)"})
|
|
367
|
+
|
|
368
|
+
# ---- 前置:模型数据质量(避免把「未映射前体」类数据问题解读成通路缺口)----
|
|
369
|
+
# 实测 iNX1344_v3:本函数报的 5 个 L3「内部通路缺口」实为 biomass 前体未映射所致,
|
|
370
|
+
# agent 为逐个证伪白烧十余次调用。这里把诊断前置到返回值里。
|
|
371
|
+
from coherence import model_coherence
|
|
372
|
+
coh = model_coherence(m)
|
|
373
|
+
out = {"L1": L1, "L2": L2, "L3": L3,
|
|
374
|
+
"medium_unresolved": unresolved_names,
|
|
375
|
+
"resolved_exchanges": sorted(resolved_med),
|
|
376
|
+
"model_coherence": {
|
|
377
|
+
"status": coh["status"],
|
|
378
|
+
"id_system": coh["id_system"]["system"],
|
|
379
|
+
"unmapped_biomass_metabolites": coh["biomass"].get("unmapped_metabolites", []),
|
|
380
|
+
}}
|
|
381
|
+
if coh["downstream_hint"]:
|
|
382
|
+
out["coherence_warning"] = coh["downstream_hint"]
|
|
383
|
+
out["interpretation_guard"] = (
|
|
384
|
+
"模型自洽性诊断发现数据质量问题(见 coherence_warning)——上方 L1/L2/L3 条目"
|
|
385
|
+
"可能是该问题的**症状**而非真实通路缺口;请先按 coherence_warning 处理,"
|
|
386
|
+
"不要把整张清单当作可交付的缺口结论")
|
|
387
|
+
return out
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _mids_exist(m, ex_id):
|
|
391
|
+
"""EX_<mid>_e0 的形如解析,检查 mid 胞外/胞内是否存在。"""
|
|
392
|
+
mm = re.match(r"EX_(\S+?)_e0$", ex_id)
|
|
393
|
+
if not mm:
|
|
394
|
+
return False
|
|
395
|
+
base = mm.group(1)
|
|
396
|
+
c0 = base + "_c0"
|
|
397
|
+
e0 = base + "_e0"
|
|
398
|
+
return (c0 in m.metabolites) or (e0 in m.metabolites)
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _c0_exists(m, sub, met_idx):
|
|
402
|
+
"""底物名 -> 胞内代谢物是否存在(决定可否规则补交换+转运)。"""
|
|
403
|
+
key = norm(sub)
|
|
404
|
+
if key in met_idx:
|
|
405
|
+
return True
|
|
406
|
+
s = SYN.get((sub or "").strip().lower())
|
|
407
|
+
return bool(s and norm(s) in met_idx)
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
if __name__ == "__main__":
|
|
411
|
+
import json, sys
|
|
412
|
+
args = json.loads(open(sys.argv[1], encoding="utf-8").read()) if len(sys.argv) > 1 else {}
|
|
413
|
+
print(json.dumps(find_gaps(args.get("model"), args.get("medium"), args.get("substrates")),
|
|
397
414
|
ensure_ascii=False, indent=2))
|