@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,591 @@
|
|
|
1
|
+
# benchmark.py — 阶段B-B1 gem_benchmark 通用基准对比(与物种无关)
|
|
2
|
+
# 六件通用能力:①介质解析两级策略复用(gapfind.build_ex_index 回退 boundary)②biomass 可行性探针
|
|
3
|
+
# ③六关 G1-G6 并列 ④必需性对比+差异归因(退化侧只报结构不做垃圾对比)⑤账本 comparison_refs 回填
|
|
4
|
+
# (update 语义幂等可重入)⑥可复现性评估。
|
|
5
|
+
# 纪律:reference_essential(如 iNX1344 论文 195)只做报告标注,严禁冒充模型输出;
|
|
6
|
+
# 任一侧 wt_growth<=EPS 时该侧 essential 集退化(判定恒真),不做差异对比。
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
import time
|
|
10
|
+
import json
|
|
11
|
+
|
|
12
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
13
|
+
|
|
14
|
+
from silentio import silent_read_sbml
|
|
15
|
+
from gapfind import expand_medium, resolve_medium, build_ex_index, \
|
|
16
|
+
ex_index_is_boundary, has_ex_layer, ex_display_name
|
|
17
|
+
from essential_scan import setup_model_medium, scan_essentiality
|
|
18
|
+
from sensitivity import find_biomass_gam
|
|
19
|
+
from validate import Validator
|
|
20
|
+
|
|
21
|
+
EPS = 1e-6
|
|
22
|
+
UNITS_NOTE = "growth=mmol/gDW/h;必需判定=敲除生长<1e-6"
|
|
23
|
+
DEG_MSG = "wt<=EPS:必需性判定恒真(v=0 使全部候选判'必需'),essential 集无生物学意义"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
# 通用件
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
def detect_id_system(m):
|
|
30
|
+
"""ID 体系探测(启发式,如实描述)。返回 {genes, reactions, metabolites, ex_style, counts}。"""
|
|
31
|
+
g_ids = [g.id for g in list(m.genes)[:20]]
|
|
32
|
+
r_ids = [r.id for r in m.reactions[:50]]
|
|
33
|
+
m_ids = [x.id for x in m.metabolites[:50]]
|
|
34
|
+
|
|
35
|
+
def _label(samples, rules):
|
|
36
|
+
for pat, label in rules:
|
|
37
|
+
if samples and all(s.startswith(pat) for s in samples):
|
|
38
|
+
return label
|
|
39
|
+
return samples[0] if samples else "n/a"
|
|
40
|
+
|
|
41
|
+
genes_label = _label(g_ids, [("NC_", "NC_XXXXXX_N(RefSeq 复制子 locus)"), ("Atu", "AtuXXXX"),
|
|
42
|
+
("b", "bXXXX(BiGG)"), ("WP", "WP_XXXX")])
|
|
43
|
+
rxn_label = _label(r_ids, [("Rnxatu", "RnxatuXXXX"), ("rxn", "rxnXXXXX_c0"), ("R_", "R_XXXX"),
|
|
44
|
+
("EX_", "EX_XXXX")])
|
|
45
|
+
met_label = _label(m_ids, [("cpd", "cpdXXXXXX_c0"), ("M0", "M00XXX_c"), ("M_", "M_XXXX_c")])
|
|
46
|
+
n_ex = sum(1 for r in m.reactions if r.id.startswith("EX_"))
|
|
47
|
+
n_bnd = sum(1 for r in m.reactions if r.boundary and len(r.metabolites) == 1)
|
|
48
|
+
if n_ex:
|
|
49
|
+
ex_style = f"EX_({n_ex} 个前缀交换反应)"
|
|
50
|
+
else:
|
|
51
|
+
ex_style = f"boundary({n_bnd} 个单代谢物 boundary 反应,无 EX_ 前缀)"
|
|
52
|
+
return {"genes": genes_label, "reactions": rxn_label, "metabolites": met_label,
|
|
53
|
+
"ex_style": ex_style, "ex_reactions": n_ex, "boundary_reactions": n_bnd}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def growth_on(model_path, medium):
|
|
57
|
+
"""G3 同款介质 setup + wt FBA。返回 (wt, resolved, unresolved, preset, boundary_style)。"""
|
|
58
|
+
m = silent_read_sbml(model_path)
|
|
59
|
+
resolved, unresolved, preset = setup_model_medium(m, medium)
|
|
60
|
+
boundary_style = ex_index_is_boundary(build_ex_index(m))
|
|
61
|
+
with m:
|
|
62
|
+
wt = m.optimize().objective_value
|
|
63
|
+
return (round(float(wt), 6) if wt is not None else 0.0), resolved, unresolved, preset, boundary_style
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def biomass_probe(model_path):
|
|
67
|
+
"""biomass 可行性探针(M5 诊断正规化):全交换开放(lb=-10)下逐组分 demand 可净产测试。
|
|
68
|
+
输出不可净产组分清单——结构性断供与介质选择无关(通用体检项)。"""
|
|
69
|
+
import cobra
|
|
70
|
+
m = silent_read_sbml(model_path)
|
|
71
|
+
gi = find_biomass_gam(m)
|
|
72
|
+
bio = m.reactions.get_by_id(gi["biomass_rxn"])
|
|
73
|
+
for r in m.reactions:
|
|
74
|
+
if r.id.startswith(("EX_", "DM_", "SK_")) or r.boundary:
|
|
75
|
+
r.lower_bound = -10.0
|
|
76
|
+
r.upper_bound = 1000.0
|
|
77
|
+
unproducible = []
|
|
78
|
+
for met in list(bio.metabolites):
|
|
79
|
+
dm = cobra.Reaction("DM_bench_probe_tmp", lower_bound=0.0, upper_bound=1000.0)
|
|
80
|
+
dm.add_metabolites({met: -1.0})
|
|
81
|
+
m.add_reactions([dm])
|
|
82
|
+
m.objective = dm
|
|
83
|
+
s = m.optimize()
|
|
84
|
+
if s.objective_value is None or s.objective_value <= 1e-9:
|
|
85
|
+
unproducible.append(met.id)
|
|
86
|
+
m.remove_reactions([dm])
|
|
87
|
+
return {"object_id": bio.id, "components": len(bio.metabolites),
|
|
88
|
+
"gam_carrier": {"type": gi["carrier_type"], "gam_orig": gi["gam_orig"]},
|
|
89
|
+
"unproducible": sorted(unproducible),
|
|
90
|
+
"probe_note": "全交换开放(lb=-10)下逐组分 demand 净产测试;不可净产=结构性断供,与介质选择无关"}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def essential_sample_scan(model_path, medium, sample_size=40):
|
|
94
|
+
"""抽检模式(G5 口径):确定性步进抽样 + 直接敲除(免 FVA 预筛)。"""
|
|
95
|
+
m = silent_read_sbml(model_path)
|
|
96
|
+
resolved, unresolved, preset = setup_model_medium(m, medium)
|
|
97
|
+
with m:
|
|
98
|
+
wt = m.optimize().objective_value
|
|
99
|
+
wt = round(float(wt), 6) if wt is not None else 0.0
|
|
100
|
+
all_ids = sorted(g.id for g in m.genes)
|
|
101
|
+
stride = max(1, len(all_ids) // max(1, sample_size))
|
|
102
|
+
subset = sorted(all_ids[::stride][:sample_size])
|
|
103
|
+
essential = []
|
|
104
|
+
for gid in subset:
|
|
105
|
+
try:
|
|
106
|
+
with m:
|
|
107
|
+
m.genes.get_by_id(gid).knock_out()
|
|
108
|
+
v = m.optimize().objective_value
|
|
109
|
+
except KeyError:
|
|
110
|
+
continue
|
|
111
|
+
if v is not None and v < EPS:
|
|
112
|
+
essential.append(gid)
|
|
113
|
+
return {"wt_growth": wt, "essential_genes": essential, "tested_genes": len(subset),
|
|
114
|
+
"sample_size": sample_size, "mode": "sample(G5 口径:确定性步进抽样直敲 40)"}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _essential_full_scan(model_path, medium, log, tag):
|
|
118
|
+
m = silent_read_sbml(model_path)
|
|
119
|
+
setup_model_medium(m, medium)
|
|
120
|
+
r = scan_essentiality(m)
|
|
121
|
+
log(f"[bench] essentiality {tag}: wt={r['wt_growth']} essential={r['essential_count']}")
|
|
122
|
+
return {"wt_growth": r["wt_growth"], "essential_genes": r["essential_genes"],
|
|
123
|
+
"tested_genes": r["tested_genes"], "mode": "full(essential_scan 可复用函数)"}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def map_genes(genes, model_b):
|
|
127
|
+
"""基因映射尽力而为(与物种无关):策略1 identity(同 id);策略2 gene.name 匹配。
|
|
128
|
+
反应桥(EC/名字)需两侧注释充分;本机两命名空间注释层不足时不启用,如实报告。"""
|
|
129
|
+
b_by_id = {g.id for g in model_b.genes}
|
|
130
|
+
b_by_name = {}
|
|
131
|
+
for g in model_b.genes:
|
|
132
|
+
if g.name:
|
|
133
|
+
b_by_name.setdefault(g.name, g.id)
|
|
134
|
+
mapping, unmapped = {}, []
|
|
135
|
+
for gid in genes:
|
|
136
|
+
if gid in b_by_id:
|
|
137
|
+
mapping[gid] = gid
|
|
138
|
+
elif gid in b_by_name:
|
|
139
|
+
mapping[gid] = b_by_name[gid]
|
|
140
|
+
else:
|
|
141
|
+
unmapped.append(gid)
|
|
142
|
+
strategy = "identity(gene id 同名) + gene.name"
|
|
143
|
+
note = ("跨命名空间(如 NC_ RefSeq locus vs Atu 旧 locus)无离线直桥时覆盖率如实为低值;"
|
|
144
|
+
"反应桥(EC/名字等价类)需两侧注释充分,注释层不足未启用(不硬造映射)。")
|
|
145
|
+
return {"mapping": mapping, "unmapped": unmapped, "strategy": strategy, "note": note}
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def essentiality_compare(path_a, path_b, medium, essential_full, log):
|
|
149
|
+
"""必需性对比。任一侧 wt<=EPS -> 该侧退化:只报结构信息,不做差异对比(严禁 155 vs 1066 垃圾对比)。
|
|
150
|
+
返回 (输出 dict, a 原始 essential set, b 原始 essential set)。"""
|
|
151
|
+
log(f"[bench] essentiality scan a ({'full' if essential_full else 'sample40'})...")
|
|
152
|
+
a = _essential_full_scan(path_a, medium, log, "a") if essential_full \
|
|
153
|
+
else essential_sample_scan(path_a, medium, 40)
|
|
154
|
+
log("[bench] essentiality scan b ...")
|
|
155
|
+
b = _essential_full_scan(path_b, medium, log, "b") if essential_full \
|
|
156
|
+
else essential_sample_scan(path_b, medium, 40)
|
|
157
|
+
|
|
158
|
+
out = {"full": bool(essential_full), "mode": a["mode"],
|
|
159
|
+
"a_wt": a["wt_growth"], "b_wt": b["wt_growth"],
|
|
160
|
+
"a_count": len(a["essential_genes"]), "b_count": len(b["essential_genes"]),
|
|
161
|
+
"a_tested": a["tested_genes"], "b_tested": b["tested_genes"],
|
|
162
|
+
"a_degenerate": a["wt_growth"] <= EPS, "b_degenerate": b["wt_growth"] <= EPS}
|
|
163
|
+
if out["a_degenerate"]:
|
|
164
|
+
out["a_note"] = DEG_MSG
|
|
165
|
+
if out["b_degenerate"]:
|
|
166
|
+
out["b_note"] = DEG_MSG
|
|
167
|
+
|
|
168
|
+
a_set, b_set = set(a["essential_genes"]), set(b["essential_genes"])
|
|
169
|
+
if out["a_degenerate"] or out["b_degenerate"]:
|
|
170
|
+
out["mapping"] = {"strategy": "not_attempted(存在退化侧)", "covered_genes": 0,
|
|
171
|
+
"coverage_ratio": 0.0, "unmapped_genes": [],
|
|
172
|
+
"map_note": "任一侧 wt<=EPS 时必需集退化,差异对比与映射无意义,已按契约跳过"}
|
|
173
|
+
out["intersection"] = None
|
|
174
|
+
out["union"] = None
|
|
175
|
+
out["a_only"] = None
|
|
176
|
+
out["b_only"] = None
|
|
177
|
+
return out, a_set, b_set
|
|
178
|
+
|
|
179
|
+
mp = map_genes(sorted(a_set), silent_read_sbml(path_b))
|
|
180
|
+
fwd = mp["mapping"]
|
|
181
|
+
b_inv = {v: k for k, v in fwd.items()}
|
|
182
|
+
a_set_m = {g for g in a_set if g in fwd}
|
|
183
|
+
inter = {g for g in a_set_m if fwd[g] in b_set}
|
|
184
|
+
a_only = {g for g in a_set_m if fwd[g] not in b_set}
|
|
185
|
+
b_only = {gb for gb in b_set if b_inv.get(gb) is None or b_inv[gb] not in a_set}
|
|
186
|
+
out["mapping"] = {"strategy": mp["strategy"], "covered_genes": len(fwd),
|
|
187
|
+
"coverage_ratio": round(len(fwd) / max(1, len(a_set)), 4),
|
|
188
|
+
"unmapped_genes": mp["unmapped"], "map_note": mp["note"]}
|
|
189
|
+
out["intersection"] = len(inter)
|
|
190
|
+
out["union"] = len(a_set | b_set)
|
|
191
|
+
out["a_only"] = sorted(a_only)
|
|
192
|
+
out["b_only"] = sorted(b_only)
|
|
193
|
+
out["compare_note"] = "差异分析在可映射子集上进行;unmapped 基因单列不参与判定。"
|
|
194
|
+
return out, a_set, b_set
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def phenotype_compare(path_a, path_b, medium, table_path, log):
|
|
198
|
+
"""G4 表型对比(sole 语义,对齐 gem_phenotype 缺口检测口径)。"""
|
|
199
|
+
out, tables = {}, {}
|
|
200
|
+
for tag, path in (("a", path_a), ("b", path_b)):
|
|
201
|
+
v = Validator(path)
|
|
202
|
+
med, _ = expand_medium(medium)
|
|
203
|
+
resolved, _ = resolve_medium(v.m, med) if med else ({}, [])
|
|
204
|
+
g4 = v.g4_phenotype(table_path=table_path, medium=resolved, carbon_mode="sole")
|
|
205
|
+
tables[tag] = {r["substrate"]: r for r in (g4.get("results") or [])}
|
|
206
|
+
out[tag] = {"matched": g4.get("matched"), "total": g4.get("total"),
|
|
207
|
+
"rate": g4.get("rate"), "carbon_mode": "sole"}
|
|
208
|
+
log(f"[bench] phenotype {tag}: {g4.get('matched')}/{g4.get('total')}")
|
|
209
|
+
table = []
|
|
210
|
+
for s in sorted(set(tables["a"]) | set(tables["b"])):
|
|
211
|
+
ra_, rb_ = tables["a"].get(s), tables["b"].get(s)
|
|
212
|
+
table.append({"substrate": s, "published": (ra_ or rb_ or {}).get("published"),
|
|
213
|
+
"a_predicted": (ra_ or {}).get("predicted"), "a_growth": (ra_ or {}).get("growth"),
|
|
214
|
+
"b_predicted": (rb_ or {}).get("predicted"), "b_growth": (rb_ or {}).get("growth"),
|
|
215
|
+
"diff": (ra_ or {}).get("predicted") != (rb_ or {}).get("predicted")})
|
|
216
|
+
out["table"] = table
|
|
217
|
+
return out
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def backfill_ledger(ledger_path, b_path, b_essential_set, b_degenerate, gene_mapping,
|
|
221
|
+
phenotype_table_rows, log):
|
|
222
|
+
"""账本 comparison_refs 回填(update 语义,不新增行;幂等可重入——同 model_b 旧条目替换)。
|
|
223
|
+
essentiality 行:b 退化或基因无映射 -> no_equivalent;有映射 -> agreed/disagreed(附 b 证据)。
|
|
224
|
+
phenotype 行:按底物在 b 表型对比表中找等价预测 -> agreed/disagreed。"""
|
|
225
|
+
import ledger as _ledger_mod
|
|
226
|
+
rows, corrupt = _ledger_mod.load_rows(ledger_path)
|
|
227
|
+
stats = {"total_predictions_checked": len(rows), "corrupt_rows": len(corrupt),
|
|
228
|
+
"agreed": 0, "disagreed": 0, "no_equivalent": 0, "updated_rows": 0}
|
|
229
|
+
if not rows:
|
|
230
|
+
stats["note"] = "账本为空"
|
|
231
|
+
return stats
|
|
232
|
+
pheno_b = {r["substrate"]: r for r in (phenotype_table_rows or [])
|
|
233
|
+
if r.get("b_predicted") is not None}
|
|
234
|
+
now = time.strftime("%Y-%m-%dT%H:%M:%S")
|
|
235
|
+
for row in rows:
|
|
236
|
+
rtype = row.get("type")
|
|
237
|
+
verdict, evidence = "no_equivalent", {}
|
|
238
|
+
if rtype == "essentiality":
|
|
239
|
+
content = row.get("content") or ""
|
|
240
|
+
gid = content.split(" 在 ")[0].strip()
|
|
241
|
+
if b_degenerate:
|
|
242
|
+
evidence = {"reason": "b 侧 essentiality 退化(wt<=EPS),无有效等价预测"}
|
|
243
|
+
else:
|
|
244
|
+
bid = (gene_mapping or {}).get(gid)
|
|
245
|
+
if bid is None:
|
|
246
|
+
evidence = {"reason": f"gene {gid} 无跨命名空间映射"}
|
|
247
|
+
else:
|
|
248
|
+
b_flag = bid in (b_essential_set or set())
|
|
249
|
+
verdict = "agreed" if b_flag else "disagreed"
|
|
250
|
+
evidence = {"b_gene": bid, "b_essential": b_flag}
|
|
251
|
+
elif rtype == "phenotype":
|
|
252
|
+
content = row.get("content") or ""
|
|
253
|
+
sub = content.split("底物 ")[-1].split(" 预测")[0].strip() if "底物 " in content else ""
|
|
254
|
+
brow = pheno_b.get(sub)
|
|
255
|
+
a_pred = 1 if "预测生长(" in content else (0 if "预测不生长" in content else None)
|
|
256
|
+
if brow is None or a_pred is None:
|
|
257
|
+
evidence = {"reason": f"底物 {sub} 在 b 表型对比中无等价结果"}
|
|
258
|
+
else:
|
|
259
|
+
verdict = "agreed" if a_pred == brow["b_predicted"] else "disagreed"
|
|
260
|
+
evidence = {"b_substrate": sub, "b_predicted": brow["b_predicted"],
|
|
261
|
+
"b_growth": brow["b_growth"]}
|
|
262
|
+
stats[verdict] += 1
|
|
263
|
+
# 幂等:同 model_b 且 verdict/evidence 完全一致 -> 不更新不计数(时间戳不参与比较)
|
|
264
|
+
old = row.get("comparison_refs") or []
|
|
265
|
+
if any(e.get("model_b") == b_path and e.get("verdict") == verdict
|
|
266
|
+
and e.get("evidence") == evidence for e in old):
|
|
267
|
+
continue
|
|
268
|
+
entry = {"model_b": b_path, "verdict": verdict, "evidence": evidence, "at": now}
|
|
269
|
+
new_refs = [e for e in old if e.get("model_b") != b_path] + [entry]
|
|
270
|
+
r = _ledger_mod.update_row(row.get("prediction_id"), comparison_refs=new_refs,
|
|
271
|
+
path=ledger_path)
|
|
272
|
+
if r.get("ok"):
|
|
273
|
+
stats["updated_rows"] += 1
|
|
274
|
+
else:
|
|
275
|
+
log(f"[bench] ledger update WARN {row.get('prediction_id')}: {r.get('error')}")
|
|
276
|
+
return stats
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def reproducibility(gates_report, probe):
|
|
280
|
+
"""可复现性评估:加载性/注释覆盖/结构性发现(notes 由真实发现自动生成)。"""
|
|
281
|
+
notes = []
|
|
282
|
+
g1, g2 = gates_report.get("g1") or {}, gates_report.get("g2") or {}
|
|
283
|
+
n_unprod = len(probe.get("unproducible") or [])
|
|
284
|
+
if n_unprod:
|
|
285
|
+
notes.append(f"biomass_probe: {n_unprod} 个 biomass 组分不可净产(结构性断供,详见 biomass_probe)")
|
|
286
|
+
fc = g2.get("metabolite_formula_coverage")
|
|
287
|
+
if fc is not None and fc < 0.9:
|
|
288
|
+
notes.append(f"formula 覆盖率 {fc}(<0.9,影响元素平衡与能量 stub 识别)")
|
|
289
|
+
gc = g1.get("gpr_gene_coverage")
|
|
290
|
+
if gc is not None and gc < 0.8:
|
|
291
|
+
notes.append(f"GPR 基因覆盖 {gc}(<0.8)")
|
|
292
|
+
return {"loadable": True, "gpr_coverage": gc, "formula_coverage": fc,
|
|
293
|
+
"reactions": g1.get("reactions"), "genes": g1.get("genes"), "notes": notes}
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def write_md(path, out):
|
|
297
|
+
"""论文级 Markdown 落盘。"""
|
|
298
|
+
L = []
|
|
299
|
+
L.append("# GEM 基准对比报告\n")
|
|
300
|
+
L.append(f"- **Model A**: `{out['model_a']}`")
|
|
301
|
+
L.append(f"- **Model B**: `{out['model_b']}`")
|
|
302
|
+
L.append(f"- **Medium**: `{json.dumps(out['medium'], ensure_ascii=False)}`")
|
|
303
|
+
L.append(f"- **Units**: {out['units_note']}")
|
|
304
|
+
L.append(f"- **Generated**: {time.strftime('%Y-%m-%dT%H:%M:%S')}\n")
|
|
305
|
+
L.append("## 1. ID 体系\n")
|
|
306
|
+
L.append("| 维度 | A | B |")
|
|
307
|
+
L.append("|---|---|---|")
|
|
308
|
+
for k in ("genes", "reactions", "metabolites", "ex_style"):
|
|
309
|
+
L.append(f"| {k} | {out['id_systems']['a'][k]} | {out['id_systems']['b'][k]} |")
|
|
310
|
+
L.append("\n## 2. 六道验证关卡(G1-G6 并列)\n")
|
|
311
|
+
L.append("| Gate | A | B |")
|
|
312
|
+
L.append("|---|---|---|")
|
|
313
|
+
for g in out["gates"]:
|
|
314
|
+
sa = json.dumps(g["a"], ensure_ascii=False)[:150] if g["a"] else "n/a"
|
|
315
|
+
sb = json.dumps(g["b"], ensure_ascii=False)[:150] if g["b"] else "n/a"
|
|
316
|
+
L.append(f"| {g['gate']} | `{sa}` | `{sb}` |")
|
|
317
|
+
L.append("\n## 3. 生长(声明介质,单点 FBA 口径)\n")
|
|
318
|
+
for tag, name in (("a", "A"), ("b", "B")):
|
|
319
|
+
g = out["growth"][tag]
|
|
320
|
+
L.append(f"- **{name}**: growth=**{g['growth']}** mmol/gDW/h, resolved={g['resolved_exchanges']}, "
|
|
321
|
+
f"boundary_style={g['boundary_style']}, unresolved={g['unresolved']}")
|
|
322
|
+
if g.get("resolved_display"):
|
|
323
|
+
L.append(f" - resolved_display: {g['resolved_display']}")
|
|
324
|
+
L.append("\n## 4. Biomass 可行性探针(结构性断供,与介质无关)\n")
|
|
325
|
+
for tag, name in (("a", "A"), ("b", "B")):
|
|
326
|
+
p = out["biomass_probe"][tag]
|
|
327
|
+
L.append(f"- **{name}**: biomass=`{p['object_id']}` ({p['components']} 组分, "
|
|
328
|
+
f"GAM carrier={p['gam_carrier']['type']}/GAM_ORIG={p['gam_carrier']['gam_orig']})")
|
|
329
|
+
L.append(f" - unproducible(不可净产): {p['unproducible']}")
|
|
330
|
+
e = out["essentiality"]
|
|
331
|
+
L.append("\n## 5. 必需性对比\n")
|
|
332
|
+
L.append(f"- mode={e['mode']}")
|
|
333
|
+
L.append(f"- A: wt={e['a_wt']}, essential={e['a_count']}/{e['a_tested']}, degenerate={e['a_degenerate']}")
|
|
334
|
+
L.append(f"- B: wt={e['b_wt']}, essential={e['b_count']}/{e['b_tested']}, degenerate={e['b_degenerate']}")
|
|
335
|
+
if e.get("a_note"):
|
|
336
|
+
L.append(f"- A note: {e['a_note']}")
|
|
337
|
+
if e.get("b_note"):
|
|
338
|
+
L.append(f"- B note: {e['b_note']}")
|
|
339
|
+
if e.get("intersection") is not None:
|
|
340
|
+
mp = e["mapping"]
|
|
341
|
+
L.append(f"- mapping: {mp['strategy']} coverage={mp['coverage_ratio']} "
|
|
342
|
+
f"({mp['covered_genes']} mapped / {len(mp['unmapped_genes'])} unmapped)")
|
|
343
|
+
L.append(f"- intersection={e['intersection']}, union={e['union']}, "
|
|
344
|
+
f"a_only={len(e['a_only'] or [])}, b_only={len(e['b_only'] or [])}")
|
|
345
|
+
if e["a_only"]:
|
|
346
|
+
L.append(f" - a_only: {e['a_only'][:20]}")
|
|
347
|
+
if e["b_only"]:
|
|
348
|
+
L.append(f" - b_only: {e['b_only'][:20]}")
|
|
349
|
+
if e["a_degenerate"] or e["b_degenerate"]:
|
|
350
|
+
L.append("- **差异对比已按契约跳过(存在退化侧),仅报结构信息——严禁 155 vs 1066 式无意义对比**")
|
|
351
|
+
if out.get("reference_essential"):
|
|
352
|
+
L.append(f"- 文献值标注(不参与计算):`{json.dumps(out['reference_essential'], ensure_ascii=False)}`")
|
|
353
|
+
if out.get("phenotype"):
|
|
354
|
+
p = out["phenotype"]
|
|
355
|
+
L.append("\n## 6. 表型对比(G4 sole 语义)\n")
|
|
356
|
+
L.append(f"- A: {p['a']['matched']}/{p['a']['total']} ({p['a']['rate']}); "
|
|
357
|
+
f"B: {p['b']['matched']}/{p['b']['total']} ({p['b']['rate']})\n")
|
|
358
|
+
L.append("| substrate | published | A_pred | A_growth | B_pred | B_growth | diff |")
|
|
359
|
+
L.append("|---|---|---|---|---|---|---|")
|
|
360
|
+
for r in p["table"]:
|
|
361
|
+
L.append(f"| {r['substrate']} | {r['published']} | {r['a_predicted']} | {r['a_growth']} | "
|
|
362
|
+
f"{r['b_predicted']} | {r['b_growth']} | {r['diff']} |")
|
|
363
|
+
L.append("\n## 7. 可复现性评估\n")
|
|
364
|
+
for tag, name in (("a", "A"), ("b", "B")):
|
|
365
|
+
r = out["reproducibility"][tag]
|
|
366
|
+
L.append(f"- **{name}**: loadable={r['loadable']}, GPR 覆盖={r['gpr_coverage']}, "
|
|
367
|
+
f"formula 覆盖={r['formula_coverage']}, reactions={r['reactions']}, genes={r['genes']}")
|
|
368
|
+
for n in r["notes"]:
|
|
369
|
+
L.append(f" - {n}")
|
|
370
|
+
if out.get("comparison_refs"):
|
|
371
|
+
c = out["comparison_refs"]
|
|
372
|
+
L.append("\n## 8. 账本 comparison_refs 回填(update 语义,幂等可重入)\n")
|
|
373
|
+
L.append(f"- checked={c['total_predictions_checked']}, agreed={c['agreed']}, "
|
|
374
|
+
f"disagreed={c['disagreed']}, no_equivalent={c['no_equivalent']}, "
|
|
375
|
+
f"updated_rows={c['updated_rows']}")
|
|
376
|
+
L.append("\n---\n*generated by gem_benchmark*")
|
|
377
|
+
data = "\n".join(L) + "\n"
|
|
378
|
+
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
|
|
379
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
380
|
+
f.write(data)
|
|
381
|
+
return len(data)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
# ---------------------------------------------------------------------------
|
|
385
|
+
# B3:模型获取层最小版(bigg:<model_id> URI -> BiGG Models REST 下载;可选增强,失败如实报告不阻塞本地对比)
|
|
386
|
+
# ---------------------------------------------------------------------------
|
|
387
|
+
def fetch_bigg_model(model_id, dest_dir=None):
|
|
388
|
+
"""从 BiGG 下载模型 SBML(B3 最小版)。
|
|
389
|
+
URL 策略(实测 2026-08-30):静态库 http://bigg.ucsd.edu/static/models/<id>.xml 返回标准 SBML;
|
|
390
|
+
任务书给的 /api/v2/universal/models/<id>/download 实为 404(universal 是 reactions 命名空间),
|
|
391
|
+
/api/v2/models/<id>/download 返回 200 但内容是 BiGG JSON(非 SBML)——两者均不采用。
|
|
392
|
+
直连失败走本机代理 127.0.0.1:27890;都失败抛错(调用方如实报告,不阻塞本地对比)。"""
|
|
393
|
+
import urllib.request
|
|
394
|
+
import shutil
|
|
395
|
+
urls = [f"http://bigg.ucsd.edu/static/models/{model_id}.xml"]
|
|
396
|
+
dest_dir = dest_dir or os.path.join(os.path.expanduser("~"), ".dsh", "dsh-bio-gem", "models")
|
|
397
|
+
os.makedirs(dest_dir, exist_ok=True)
|
|
398
|
+
dest = os.path.join(dest_dir, f"bigg_{model_id}.xml")
|
|
399
|
+
if os.path.exists(dest) and os.path.getsize(dest) > 100000:
|
|
400
|
+
return dest, "cached(已有本地副本)"
|
|
401
|
+
last_err = None
|
|
402
|
+
for url in urls:
|
|
403
|
+
for tag, proxies in (("direct", None), ("proxy", {"http": "http://127.0.0.1:27890",
|
|
404
|
+
"https": "http://127.0.0.1:27890"})):
|
|
405
|
+
try:
|
|
406
|
+
t0 = time.time()
|
|
407
|
+
req = urllib.request.Request(url, headers={"User-Agent": "dsh-bio-gem benchmark/1.0"})
|
|
408
|
+
opener = urllib.request.build_opener(urllib.request.ProxyHandler(proxies or {}))
|
|
409
|
+
with opener.open(req, timeout=120) as resp, open(dest + ".tmp", "wb") as f:
|
|
410
|
+
shutil.copyfileobj(resp, f)
|
|
411
|
+
head = open(dest + ".tmp", "rb").read(100)
|
|
412
|
+
if not head.lstrip().startswith(b"<?xml"):
|
|
413
|
+
raise ValueError(f"内容非 SBML XML(head={head[:40]!r})")
|
|
414
|
+
os.replace(dest + ".tmp", dest)
|
|
415
|
+
return dest, (f"downloaded via {tag} ({os.path.getsize(dest)} bytes, {time.time() - t0:.1f}s)")
|
|
416
|
+
except Exception as e:
|
|
417
|
+
last_err = f"{type(e).__name__}: {str(e)[:200]}"
|
|
418
|
+
if os.path.exists(dest + ".tmp"):
|
|
419
|
+
os.remove(dest + ".tmp")
|
|
420
|
+
raise RuntimeError(f"bigg download failed(直连+代理均失败): {last_err}")
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def medium_adaptation_hints(model_path, medium, max_hints=5, progress=None):
|
|
424
|
+
"""阶段C-C5:B3 molybdate 式诊断自动化(通用函数)。介质下 wt<=EPS 时,在严格介质基础上
|
|
425
|
+
逐个补充模型其它交换反应(单变量 lb=-10 试验),报告能恢复生长的成分 -> '介质疑似缺 X'。"""
|
|
426
|
+
m = silent_read_sbml(model_path)
|
|
427
|
+
resolved, unresolved, preset = setup_model_medium(m, medium)
|
|
428
|
+
with m:
|
|
429
|
+
wt = m.optimize().objective_value
|
|
430
|
+
wt = round(float(wt), 6) if wt is not None else 0.0
|
|
431
|
+
if wt > EPS:
|
|
432
|
+
return {"applicable": False, "wt_growth": wt, "hints": [],
|
|
433
|
+
"note": "介质下可生长,无需适配诊断"}
|
|
434
|
+
cands = [r for r in m.reactions
|
|
435
|
+
if (r.id.startswith(("EX_", "DM_", "SK_")) or r.boundary) and r.id not in resolved]
|
|
436
|
+
hints = []
|
|
437
|
+
for r in sorted(cands, key=lambda x: x.id):
|
|
438
|
+
with m:
|
|
439
|
+
r.lower_bound = -10.0
|
|
440
|
+
v = m.slim_optimize()
|
|
441
|
+
if v is not None and v > EPS:
|
|
442
|
+
met = next(iter(r.metabolites))
|
|
443
|
+
hints.append({"exchange": r.id, "met_id": met.id, "name": met.name or "",
|
|
444
|
+
"recovered_growth": round(float(v), 6)})
|
|
445
|
+
if len(hints) >= max_hints:
|
|
446
|
+
break
|
|
447
|
+
return {"applicable": True, "wt_growth": wt, "trials": len(cands), "hints": hints,
|
|
448
|
+
"note": ("在严格介质基础上逐个补充单一成分(lb=-10)能恢复生长的交换;"
|
|
449
|
+
"多成分协同缺失不在本诊断范围(如 iNX1344 的 5 组分断供)")}
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def _resolve_model_arg(p):
|
|
453
|
+
"""model_a/model_b 支持本地路径或 bigg:<model_id> URI。返回 (实际路径, fetch_note 或 None)。"""
|
|
454
|
+
if p and p.startswith("bigg:"):
|
|
455
|
+
path, how = fetch_bigg_model(p[5:].strip())
|
|
456
|
+
return path, how
|
|
457
|
+
return p, None
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def benchmark(model_a, model_b, medium=None, phenotype_table=None, reference_essential=None,
|
|
461
|
+
essential_full=False, ledger_refs=True, export_md=None, ledger_path=None,
|
|
462
|
+
progress=None):
|
|
463
|
+
log = progress or (lambda s: sys.stderr.write(str(s) + "\n"))
|
|
464
|
+
medium = medium or {"medium_name": "AB"}
|
|
465
|
+
t0 = time.time()
|
|
466
|
+
fetch_notes = {}
|
|
467
|
+
for tag, p in (("a", model_a), ("b", model_b)):
|
|
468
|
+
resolved_p, how = _resolve_model_arg(p)
|
|
469
|
+
if how:
|
|
470
|
+
fetch_notes[tag] = {"arg": p, "resolved": resolved_p, "how": how}
|
|
471
|
+
log(f"[bench] model_{tag} fetch: {p} -> {resolved_p} ({how})")
|
|
472
|
+
if tag == "a":
|
|
473
|
+
model_a = resolved_p
|
|
474
|
+
else:
|
|
475
|
+
model_b = resolved_p
|
|
476
|
+
log(f"[bench] A={model_a}")
|
|
477
|
+
log(f"[bench] B={model_b}")
|
|
478
|
+
log(f"[bench] medium={medium} essential_full={essential_full} ledger_refs={ledger_refs}")
|
|
479
|
+
|
|
480
|
+
# ①③ 六关并列
|
|
481
|
+
gate_reports = {}
|
|
482
|
+
for tag, path in (("a", model_a), ("b", model_b)):
|
|
483
|
+
rep = Validator(path).run(medium=medium)
|
|
484
|
+
gate_reports[tag] = rep
|
|
485
|
+
log(f"[bench] gates {tag}: overall={rep.get('overall')} "
|
|
486
|
+
f"g3={rep['g3']['status']}/{rep['g3']['growth_medium']}")
|
|
487
|
+
gates = [{"gate": g, "a": gate_reports["a"].get(g), "b": gate_reports["b"].get(g)}
|
|
488
|
+
for g in ("g1", "g2", "g3", "g4", "g5", "g6")]
|
|
489
|
+
|
|
490
|
+
# growth(介质解析计数 + boundary 规范展示)
|
|
491
|
+
growth = {}
|
|
492
|
+
for tag, path in (("a", model_a), ("b", model_b)):
|
|
493
|
+
wt, resolved, unresolved, preset, bstyle = growth_on(path, medium)
|
|
494
|
+
entry = {"growth": wt, "resolved_exchanges": len(resolved), "unresolved": unresolved,
|
|
495
|
+
"medium_preset": preset, "boundary_style": bstyle, "units": "mmol/gDW/h",
|
|
496
|
+
"point_value_note": "单点 FBA 值,非解空间硬结论;条件间对比用 gem_fluxscan(区间制)"}
|
|
497
|
+
if bstyle:
|
|
498
|
+
entry["resolved_display"] = [ex_display_name(silent_read_sbml(path), rid)
|
|
499
|
+
for rid in sorted(resolved)]
|
|
500
|
+
growth[tag] = entry
|
|
501
|
+
log(f"[bench] growth {tag}: {wt} resolved={len(resolved)} boundary_style={bstyle}")
|
|
502
|
+
|
|
503
|
+
# ② biomass 探针
|
|
504
|
+
probe = {}
|
|
505
|
+
for tag, path in (("a", model_a), ("b", model_b)):
|
|
506
|
+
probe[tag] = biomass_probe(path)
|
|
507
|
+
log(f"[bench] biomass_probe {tag}: {probe[tag]['object_id']} components={probe[tag]['components']} "
|
|
508
|
+
f"unproducible={len(probe[tag]['unproducible'])}")
|
|
509
|
+
|
|
510
|
+
# ④ 必需性对比(退化护栏前置)
|
|
511
|
+
ess, a_set, b_set = essentiality_compare(model_a, model_b, medium, essential_full, log)
|
|
512
|
+
|
|
513
|
+
# ⑤⑥ 表型对比 + 可复现性
|
|
514
|
+
pheno = phenotype_compare(model_a, model_b, medium, phenotype_table, log) \
|
|
515
|
+
if phenotype_table and os.path.exists(phenotype_table) else None
|
|
516
|
+
hints_out = {}
|
|
517
|
+
for tag in ("a", "b"):
|
|
518
|
+
if growth[tag]["growth"] <= EPS:
|
|
519
|
+
try:
|
|
520
|
+
hints_out[tag] = medium_adaptation_hints(
|
|
521
|
+
{"a": model_a, "b": model_b}[tag], medium)
|
|
522
|
+
log(f"[bench] medium_adaptation_hints {tag}: "
|
|
523
|
+
f"{[h['met_id'] for h in hints_out[tag]['hints']]}")
|
|
524
|
+
except Exception as e:
|
|
525
|
+
log(f"[bench] hints {tag} WARN: {e}")
|
|
526
|
+
hints_out = hints_out or None
|
|
527
|
+
|
|
528
|
+
repro = {tag: reproducibility(gate_reports[tag], probe[tag])
|
|
529
|
+
for tag in ("a", "b")}
|
|
530
|
+
if not has_ex_layer(silent_read_sbml(model_b)):
|
|
531
|
+
repro["b"]["notes"].insert(
|
|
532
|
+
0, "无 EX_ 前缀交换层(boundary 单代谢物反应充当交换),介质层按两级策略回退(boundary_style=true)")
|
|
533
|
+
|
|
534
|
+
# ⑤ 账本回填(update 语义幂等;全账本基因一次映射)
|
|
535
|
+
# 2026-08-31:一个模型一个账本——ledger_path 缺省 = 模型 A 自己的账本
|
|
536
|
+
refs_stats = None
|
|
537
|
+
if ledger_refs:
|
|
538
|
+
import ledger as _ledger_mod
|
|
539
|
+
lp = ledger_path or _ledger_mod.model_ledger_path(model_a)
|
|
540
|
+
rows, _ = _ledger_mod.load_rows(lp)
|
|
541
|
+
a_genes = sorted({(r.get("content") or "").split(" 在 ")[0].strip()
|
|
542
|
+
for r in rows if r.get("type") == "essentiality"})
|
|
543
|
+
mp_all = map_genes(a_genes, silent_read_sbml(model_b))
|
|
544
|
+
refs_stats = backfill_ledger(lp, model_b, b_set, ess["b_degenerate"],
|
|
545
|
+
mp_all["mapping"], (pheno or {}).get("table"), log)
|
|
546
|
+
log(f"[bench] ledger backfill: {json.dumps(refs_stats, ensure_ascii=False)}")
|
|
547
|
+
|
|
548
|
+
out = {
|
|
549
|
+
"model_a": model_a, "model_b": model_b, "medium": medium,
|
|
550
|
+
**({"model_fetch": fetch_notes} if fetch_notes else {}),
|
|
551
|
+
"id_systems": {"a": detect_id_system(silent_read_sbml(model_a)),
|
|
552
|
+
"b": detect_id_system(silent_read_sbml(model_b))},
|
|
553
|
+
"gates": gates,
|
|
554
|
+
"growth": growth,
|
|
555
|
+
"biomass_probe": probe,
|
|
556
|
+
"essentiality": ess,
|
|
557
|
+
"phenotype": pheno,
|
|
558
|
+
"reproducibility": repro,
|
|
559
|
+
**({"medium_adaptation_hints": hints_out} if hints_out else {}),
|
|
560
|
+
"ledger_refs": bool(ledger_refs),
|
|
561
|
+
"reference_essential": reference_essential,
|
|
562
|
+
"reference_note": ("reference_essential 为文献值,仅并列标注(文献值 vs 本工具重算值),"
|
|
563
|
+
"不参与任何计算、严禁冒充模型输出" if reference_essential else None),
|
|
564
|
+
"units_note": UNITS_NOTE,
|
|
565
|
+
"timing_seconds": round(time.time() - t0, 1),
|
|
566
|
+
}
|
|
567
|
+
if refs_stats is not None:
|
|
568
|
+
out["comparison_refs"] = refs_stats
|
|
569
|
+
if export_md:
|
|
570
|
+
n = write_md(export_md, out)
|
|
571
|
+
out["summary_md_path"] = export_md
|
|
572
|
+
out["summary_md_bytes"] = n
|
|
573
|
+
log(f"[bench] md -> {export_md} ({n} bytes)")
|
|
574
|
+
return out
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
if __name__ == "__main__":
|
|
578
|
+
args = {}
|
|
579
|
+
if len(sys.argv) > 1:
|
|
580
|
+
with open(sys.argv[1], encoding="utf-8") as f:
|
|
581
|
+
args = json.load(f)
|
|
582
|
+
elif not sys.stdin.isatty():
|
|
583
|
+
args = json.loads(sys.stdin.read())
|
|
584
|
+
a = args.get("args", args)
|
|
585
|
+
print(json.dumps({"ok": True, "result": benchmark(
|
|
586
|
+
a.get("model_a"), a.get("model_b"), medium=a.get("medium"),
|
|
587
|
+
phenotype_table=a.get("phenotype_table"),
|
|
588
|
+
reference_essential=a.get("reference_essential"),
|
|
589
|
+
essential_full=a.get("essential_full", False),
|
|
590
|
+
ledger_refs=a.get("ledger_refs", True), export_md=a.get("export_md"),
|
|
591
|
+
ledger_path=a.get("ledger_path"))}, ensure_ascii=False))
|