@dsh-bio/dsh-bio-gem 0.1.1
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/LICENSE +21 -0
- package/README.md +194 -0
- package/cordis.patch.yml +7 -0
- package/docs/ARCHITECTURE.md +116 -0
- package/docs/DECISIONS-2026-08-29.md +37 -0
- package/docs/DECISIONS-/351/230/266/346/256/265A.md +67 -0
- package/docs/DECISIONS-/351/230/266/346/256/265E.md +56 -0
- package/index.js +5 -0
- package/package.json +50 -0
- package/python/annotate.py +208 -0
- package/python/benchmark.py +591 -0
- package/python/biomass_tools.py +329 -0
- package/python/budget.py +53 -0
- package/python/build.py +343 -0
- package/python/build_whitelist.py +127 -0
- package/python/double_knockout.py +201 -0
- package/python/enrichment.py +182 -0
- package/python/essential_scan.py +195 -0
- package/python/fluxscan.py +302 -0
- package/python/gapfill.py +176 -0
- package/python/gapfind.py +397 -0
- package/python/gapseq_wsl.py +251 -0
- package/python/gem_ops.py +520 -0
- package/python/l3_fix.py +641 -0
- package/python/ledger.py +581 -0
- package/python/model_card.py +248 -0
- package/python/phenotype_fix.py +115 -0
- package/python/roundtrip_check.py +45 -0
- package/python/secretion.py +179 -0
- package/python/sensitivity.py +484 -0
- package/python/silentio.py +28 -0
- package/python/targets.py +151 -0
- package/python/validate.py +393 -0
- package/skills/gem-expert.md +88 -0
- package/src/index.js +19 -0
- package/src/jobs.js +152 -0
- package/src/python.js +64 -0
- package/src/skills.js +29 -0
- package/src/tools.js +545 -0
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# gapfill.py — dsh-bio-gem 规则级补洞(L1 交换 / L2 转运,M1 不做 L3/MILP)
|
|
2
|
+
# 防过补四闸门(ARCHITECTURE §6):规则优先、max_add 封顶、逐条 provenance、修复后建议重验。
|
|
3
|
+
# 用法: apply_fixes(model, medium=None, substrates=None, max_add=20, out=None)
|
|
4
|
+
# -> {"applied": [...], "skipped": [...], "out": path}
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import sys
|
|
8
|
+
import cobra
|
|
9
|
+
|
|
10
|
+
# Python -I isolated 模式下脚本目录不进 sys.path——显式插入以导入同目录 gapfind
|
|
11
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
12
|
+
|
|
13
|
+
from gapfind import find_gaps, match_ex, build_ex_index, build_met_index, SYN, MET_ALIAS, norm
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _new_e0_met(m, c0_id, tag):
|
|
17
|
+
"""由胞内代谢物复制胞外 e0 版本(name/formula/charge 继承,compartment=e0)。"""
|
|
18
|
+
src = m.metabolites.get_by_id(c0_id)
|
|
19
|
+
e0_id = c0_id[:-3] + "_e0" if c0_id.endswith("_c0") else c0_id + "_e0"
|
|
20
|
+
if e0_id in m.metabolites:
|
|
21
|
+
return m.metabolites.get_by_id(e0_id)
|
|
22
|
+
new = cobra.Metabolite(e0_id, name=(src.name or ""),
|
|
23
|
+
compartment="e0", formula=src.formula, charge=src.charge)
|
|
24
|
+
new.notes["source"] = "gem-gapfill"
|
|
25
|
+
new.notes["reason"] = f"L1: extracellular form of {c0_id} for exchange ({tag})"
|
|
26
|
+
m.add_metabolites([new])
|
|
27
|
+
return new
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _add_ex_rxn(m, e0_met, tag):
|
|
31
|
+
ex_id = "EX_" + e0_met.id
|
|
32
|
+
if ex_id in m.reactions:
|
|
33
|
+
return None
|
|
34
|
+
r = cobra.Reaction(ex_id, name=(e0_met.name or "") + " exchange",
|
|
35
|
+
lower_bound=0.0, upper_bound=1000.0)
|
|
36
|
+
r.add_metabolites({e0_met: -1})
|
|
37
|
+
m.add_reactions([r])
|
|
38
|
+
r.notes["source"] = "gem-gapfill"
|
|
39
|
+
r.notes["evidence"] = "EVIDENCE_rule"
|
|
40
|
+
r.notes["reason"] = f"L1: add exchange for medium component ({tag})"
|
|
41
|
+
return r
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _add_tx_rxn(m, e0_id, c0_id, tag):
|
|
45
|
+
tx_id = "rxnGEM_tx_" + (e0_id.split("_")[0] if "_" in e0_id else e0_id)
|
|
46
|
+
if tx_id in m.reactions:
|
|
47
|
+
return None
|
|
48
|
+
r = cobra.Reaction(tx_id, name=(m.metabolites.get_by_id(e0_id).name or "") + " transport (gem)",
|
|
49
|
+
lower_bound=-1000.0, upper_bound=1000.0) # 转运默认开启(双向),否则补洞后仍无法流入
|
|
50
|
+
r.add_metabolites({m.metabolites.get_by_id(e0_id): -1,
|
|
51
|
+
m.metabolites.get_by_id(c0_id): 1})
|
|
52
|
+
m.add_reactions([r])
|
|
53
|
+
r.notes["source"] = "gem-gapfill"
|
|
54
|
+
r.notes["evidence"] = "EVIDENCE_rule"
|
|
55
|
+
r.notes["reason"] = f"L2: connect extracellular {e0_id} to cytosol ({tag})"
|
|
56
|
+
return r
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def apply_fixes(model_path, medium=None, substrates=None, max_add=20, out=None, confirm_budget=False):
|
|
60
|
+
from silentio import silent_read_sbml, silent_write_sbml
|
|
61
|
+
if not (medium or substrates):
|
|
62
|
+
return {"error": "nothing to fix: provide medium and/or substrates"}
|
|
63
|
+
gaps = find_gaps(model_path, medium=medium, substrates=substrates)
|
|
64
|
+
m = silent_read_sbml(model_path)
|
|
65
|
+
# ---- 防过补第五闸门(全局预算):本批计划 = 规则可修的 L1/L2 缺口数 ----
|
|
66
|
+
from budget import budget_gate
|
|
67
|
+
planned = sum(1 for g in (gaps["L1"] + gaps["L2"]) if g.get("fixable") == "yes")
|
|
68
|
+
gate = budget_gate(m, planned=planned, confirm_budget=confirm_budget)
|
|
69
|
+
if gate:
|
|
70
|
+
return {"ok": False, **gate, "gaps": {"L1": len(gaps["L1"]), "L2": len(gaps["L2"]),
|
|
71
|
+
"L3": len(gaps["L3"])}}
|
|
72
|
+
ex_idx = build_ex_index(m)
|
|
73
|
+
met_idx = build_met_index(m)
|
|
74
|
+
applied, skipped = [], []
|
|
75
|
+
|
|
76
|
+
def _budget():
|
|
77
|
+
return len(applied) < max_add
|
|
78
|
+
|
|
79
|
+
# ---- L1: 补交换 ----
|
|
80
|
+
for g in gaps["L1"]:
|
|
81
|
+
if not _budget():
|
|
82
|
+
skipped.append({**g, "why": "max_add cap"})
|
|
83
|
+
continue
|
|
84
|
+
if g["type"] == "exchange_missing":
|
|
85
|
+
mm = g["exchange"][3:] # 去掉 EX_
|
|
86
|
+
base = mm[:-3] if mm.endswith("_e0") else mm
|
|
87
|
+
c0 = base + "_c0"
|
|
88
|
+
if c0 not in m.metabolites:
|
|
89
|
+
skipped.append({**g, "why": "no c0 metabolite to copy (unfixable by rules)"})
|
|
90
|
+
continue
|
|
91
|
+
e0 = _new_e0_met(m, c0, g["exchange"])
|
|
92
|
+
exr = _add_ex_rxn(m, e0, g["exchange"])
|
|
93
|
+
txr = _add_tx_rxn(m, e0.id, c0, g["exchange"])
|
|
94
|
+
if exr or txr:
|
|
95
|
+
applied.append({"type": "L1_exchange", "exchange": g["exchange"],
|
|
96
|
+
"metabolite_e0": e0.id, "ex_rxn": exr.id if exr else None,
|
|
97
|
+
"tx_rxn": txr.id if txr else None})
|
|
98
|
+
elif g["type"] in ("exchange_missing_name", "exchange_unresolved_name"):
|
|
99
|
+
key = norm(g["substrate"])
|
|
100
|
+
sid = met_idx.get(key)
|
|
101
|
+
if not sid:
|
|
102
|
+
s = SYN.get(g["substrate"].strip().lower())
|
|
103
|
+
if s:
|
|
104
|
+
sid = met_idx.get(norm(s))
|
|
105
|
+
if not sid:
|
|
106
|
+
# MET_ALIAS:自然名 -> BiGG 短名(如 gluconate -> glcn)
|
|
107
|
+
al = MET_ALIAS.get((g["substrate"] or "").strip().lower())
|
|
108
|
+
if al:
|
|
109
|
+
sid = met_idx.get(norm(al))
|
|
110
|
+
if not sid:
|
|
111
|
+
skipped.append({**g, "why": "no c0 metabolite by name (unfixable by rules)"})
|
|
112
|
+
continue
|
|
113
|
+
c0 = sid
|
|
114
|
+
e0 = _new_e0_met(m, c0, g["substrate"])
|
|
115
|
+
exr = _add_ex_rxn(m, e0, g["substrate"])
|
|
116
|
+
txr = _add_tx_rxn(m, e0.id, c0, g["substrate"])
|
|
117
|
+
if exr or txr:
|
|
118
|
+
applied.append({"type": "L1_exchange", "substrate": g["substrate"],
|
|
119
|
+
"metabolite_e0": e0.id, "ex_rxn": exr.id if exr else None,
|
|
120
|
+
"tx_rxn": txr.id if txr else None})
|
|
121
|
+
|
|
122
|
+
# ---- L2: 补转运 ----
|
|
123
|
+
for g in gaps["L2"]:
|
|
124
|
+
if not _budget():
|
|
125
|
+
skipped.append({**g, "why": "max_add cap"})
|
|
126
|
+
continue
|
|
127
|
+
if g["fixable"] != "yes":
|
|
128
|
+
skipped.append({**g, "why": "no c0 metabolite (unfixable by rules)"})
|
|
129
|
+
continue
|
|
130
|
+
e0_id = g["metabolite_e0"]
|
|
131
|
+
c0_id = g["metabolite_c0"]
|
|
132
|
+
txr = _add_tx_rxn(m, e0_id, c0_id, e0_id)
|
|
133
|
+
if txr:
|
|
134
|
+
applied.append({"type": "L2_transport", "metabolite_e0": e0_id,
|
|
135
|
+
"metabolite_c0": c0_id, "tx_rxn": txr.id})
|
|
136
|
+
else:
|
|
137
|
+
skipped.append({**g, "why": "tx reaction already exists"})
|
|
138
|
+
|
|
139
|
+
# ---- L3: 仅报告(M1 不自动补)----
|
|
140
|
+
# ---- 保存 ----
|
|
141
|
+
if not applied:
|
|
142
|
+
return {"applied": [], "skipped": skipped, "gaps": gaps,
|
|
143
|
+
"out": None, "note": "no rule-level fix applied"}
|
|
144
|
+
out_path = out or (model_path[:-4] + "_gf.xml" if model_path.endswith(".xml") else model_path + "_gf.xml")
|
|
145
|
+
backup = None
|
|
146
|
+
# 只有就地覆写(out==model)才备份原文件;输出到新文件时原模型未被改动,无需备份
|
|
147
|
+
if os.path.normpath(out_path) == os.path.normpath(model_path):
|
|
148
|
+
backup = model_path + ".bak"
|
|
149
|
+
if not os.path.exists(backup):
|
|
150
|
+
shutil.copy2(model_path, backup)
|
|
151
|
+
silent_write_sbml(m, out_path)
|
|
152
|
+
# 模型卡 lineage 追加(源模型旁有 card 才传播+追加;无卡不凭空造卡)
|
|
153
|
+
card_version = None
|
|
154
|
+
try:
|
|
155
|
+
from model_card import append_operation, load_card, propagate_card
|
|
156
|
+
propagate_card(model_path, out_path)
|
|
157
|
+
if load_card(out_path) is not None:
|
|
158
|
+
card = append_operation(out_path, "gapfill", reactions_added=len(applied),
|
|
159
|
+
detail={"L1": sum(1 for a in applied if a["type"] == "L1_exchange"),
|
|
160
|
+
"L2": sum(1 for a in applied if a["type"] == "L2_transport")})
|
|
161
|
+
card_version = (card or {}).get("model_lineage", {}).get("version")
|
|
162
|
+
except Exception:
|
|
163
|
+
pass
|
|
164
|
+
return {"applied": applied, "skipped": skipped, "gaps": {
|
|
165
|
+
"L1": len(gaps["L1"]), "L2": len(gaps["L2"]), "L3": len(gaps["L3"])},
|
|
166
|
+
"out": out_path, "backup": backup, "fixed_rxns": len(applied),
|
|
167
|
+
"card_version": card_version}
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
if __name__ == "__main__":
|
|
171
|
+
import json, sys
|
|
172
|
+
args = json.loads(open(sys.argv[1], encoding="utf-8").read()) if len(sys.argv) > 1 else {}
|
|
173
|
+
print(json.dumps(apply_fixes(args.get("model"), args.get("medium"),
|
|
174
|
+
args.get("substrates"), args.get("max_add", 20),
|
|
175
|
+
args.get("out"), args.get("confirm_budget", False)),
|
|
176
|
+
ensure_ascii=False, indent=2))
|
|
@@ -0,0 +1,397 @@
|
|
|
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
|
+
return {"L1": L1, "L2": L2, "L3": L3,
|
|
369
|
+
"medium_unresolved": unresolved_names,
|
|
370
|
+
"resolved_exchanges": sorted(resolved_med)}
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _mids_exist(m, ex_id):
|
|
374
|
+
"""EX_<mid>_e0 的形如解析,检查 mid 胞外/胞内是否存在。"""
|
|
375
|
+
mm = re.match(r"EX_(\S+?)_e0$", ex_id)
|
|
376
|
+
if not mm:
|
|
377
|
+
return False
|
|
378
|
+
base = mm.group(1)
|
|
379
|
+
c0 = base + "_c0"
|
|
380
|
+
e0 = base + "_e0"
|
|
381
|
+
return (c0 in m.metabolites) or (e0 in m.metabolites)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _c0_exists(m, sub, met_idx):
|
|
385
|
+
"""底物名 -> 胞内代谢物是否存在(决定可否规则补交换+转运)。"""
|
|
386
|
+
key = norm(sub)
|
|
387
|
+
if key in met_idx:
|
|
388
|
+
return True
|
|
389
|
+
s = SYN.get((sub or "").strip().lower())
|
|
390
|
+
return bool(s and norm(s) in met_idx)
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
if __name__ == "__main__":
|
|
394
|
+
import json, sys
|
|
395
|
+
args = json.loads(open(sys.argv[1], encoding="utf-8").read()) if len(sys.argv) > 1 else {}
|
|
396
|
+
print(json.dumps(find_gaps(args.get("model"), args.get("medium"), args.get("substrates")),
|
|
397
|
+
ensure_ascii=False, indent=2))
|