@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.
@@ -0,0 +1,343 @@
1
+ # build.py — dsh-bio-gem M1:CarveMe 基因组→SBML 构建(纯 Windows)
2
+ # 流程: 输入(protein.faa 优先; accesson 与 GFF+fna 二期待支持) -> carve 子进程
3
+ # -> validate G1-G3 -> 若 G3 FAIL 且给了 medium -> gapfind/gapfill 闭环 -> 模型卡 sidecar
4
+ # 进度: 独立 CLI 模式将事件写入 <out>.progress.jsonl(TS 层 jobs.js 轮询);CARVE_CMD 可被 env 覆盖。
5
+ import json
6
+ import os
7
+ import subprocess
8
+ import sys
9
+ import tempfile
10
+ import time
11
+ import datetime
12
+
13
+ # Python -I isolated 模式下脚本目录不进 sys.path——显式插入以导入同目录模块
14
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
15
+
16
+ DEFAULT_CARVE_VENV = os.path.join(os.path.expanduser("~"), ".dsh", "dsh-bio-gem", "venv-carveme")
17
+ MODEL_ROOT = os.path.join(os.path.expanduser("~"), ".dsh", "dsh-bio-gem", "models")
18
+
19
+
20
+ def _log(progress_path, event):
21
+ ev = {"ts": time.time(), **event}
22
+ with open(progress_path, "a", encoding="utf-8") as f:
23
+ f.write(json.dumps(ev, ensure_ascii=False) + "\n")
24
+
25
+
26
+ def _carve_exe(venv=None):
27
+ venv = venv or DEFAULT_CARVE_VENV
28
+ exe = os.path.join(venv, "Scripts", "carve.exe")
29
+ if not os.path.exists(exe):
30
+ exe = "carve" # 退回 PATH
31
+ return exe
32
+
33
+
34
+ def resolve_input(input_spec, progress_path=None, engine="carveme"):
35
+ """输入归一化。
36
+ engine=carveme: *.faa(蛋白);engine=gapseq: *.fna(核苷酸)——gapseq 吃 DNA。
37
+ accession 下载二期待支持。"""
38
+ if not input_spec:
39
+ raise ValueError("input required (protein.faa / accession / local files)")
40
+ if input_spec.lower().startswith(("gcf_", "gca_")):
41
+ raise NotImplementedError(
42
+ "accession 下载二期待支持:请先用 datasets CLI 或 NCBI 下载蛋白/基因组,再传入本地路径")
43
+ p = input_spec
44
+ if not os.path.exists(p):
45
+ d = os.path.dirname(p)
46
+ cands = []
47
+ if os.path.isdir(d):
48
+ cands = [f for f in os.listdir(d)
49
+ if f.lower().endswith((".fna", ".faa", ".fasta", ".fa"))][:6]
50
+ raise ValueError(f"输入文件不存在: {p};目录内候选: {cands or '无'}")
51
+ low = p.lower()
52
+ if engine == "gapseq":
53
+ if low.endswith((".fna", ".fasta", ".fa", ".fna.gz", ".fasta.gz")):
54
+ return p
55
+ raise ValueError(f"gapseq 引擎需要核苷酸 fasta(.fna/.fasta):{p}")
56
+ if low.endswith((".faa", ".fasta", ".fa", ".faa.gz", ".fasta.gz")):
57
+ return p
58
+ if engine == "carveme" and low.endswith((".fna", ".fna.gz")):
59
+ # 路线 P0:裸/带注释基因组 -> 注释层 -> 蛋白(官方优先 + pyrodigal 兜底)
60
+ from annotate import nucleotide_to_protein
61
+ faa, src, stats = nucleotide_to_protein(p)
62
+ print(f"[annotate] source={src} seqs={stats.get('seqs')} -> {faa}")
63
+ return faa
64
+ raise ValueError(f"unsupported input type: {p}(carveme 请提供 protein.faa 或 genomic.fna;gapseq 请提供 genomic.fna)")
65
+
66
+
67
+ def run_carve(proteins, out_xml, venv=None, progress_path=None, timeout=3600, gapfill_medium="M9"):
68
+ exe = _carve_exe(venv)
69
+ cmd = [exe, proteins, "-o", out_xml, "-g", gapfill_medium]
70
+ _log(progress_path, {"event": "carve_start", "cmd": " ".join(cmd)})
71
+ st = time.time()
72
+ env = dict(os.environ)
73
+ # carve 从 PATH 找 diamond(Windows venv 不激活时 Scripts 不在 PATH)——显式注入
74
+ script_dir = os.path.join(venv or DEFAULT_CARVE_VENV, "Scripts")
75
+ if script_dir and script_dir not in env.get("PATH", ""):
76
+ env["PATH"] = script_dir + os.pathsep + env.get("PATH", "")
77
+ try:
78
+ r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout,
79
+ env=env, encoding="utf-8", errors="replace")
80
+ except subprocess.TimeoutExpired:
81
+ _log(progress_path, {"event": "carve_timeout", "s": int(timeout)})
82
+ raise
83
+ dt = time.time() - st
84
+ if r.returncode != 0:
85
+ _log(progress_path, {"event": "carve_fail", "rc": r.returncode,
86
+ "stderr_tail": (r.stderr or "")[-800:],
87
+ "stdout_tail": (r.stdout or "")[-400:]})
88
+ raise RuntimeError(f"carve failed rc={r.returncode}: {(r.stderr or '')[-800:]}")
89
+ _log(progress_path, {"event": "carve_done", "s": round(dt, 1),
90
+ "stdout_tail": (r.stdout or "")[-300:]})
91
+ return out_xml
92
+
93
+
94
+ def _active_medium(m):
95
+ """模型当前打开的交换 -> {EX_id: lb}(carve gapfill 实际设置的介质,可溯源)。
96
+ 注意:CarveMe 默认把所有 EX 设成开放(-1000),故全开交换数≠真介质成分;
97
+ 精确介质请用 _media_db_exchanges()。"""
98
+ return {r.id: r.lower_bound for r in m.reactions
99
+ if r.id.startswith("EX_") and r.lower_bound < 0}
100
+
101
+
102
+ def _media_db_exchanges(m, medium_name="M9", default_lb=-10.0):
103
+ """从 carveme 自带 media_db.tsv 提取介质成分 -> 模型 EX 交换字典(精确介质)。"""
104
+ import csv
105
+ db = os.path.join(DEFAULT_CARVE_VENV, "Lib", "site-packages", "carveme",
106
+ "data", "input", "media_db.tsv")
107
+ if not os.path.exists(db):
108
+ return _active_medium(m) # 退化:全部开放交换
109
+ comps = set()
110
+ with open(db, encoding="utf-8") as f:
111
+ rd = csv.DictReader(f, delimiter="\t")
112
+ for row in rd:
113
+ if row.get("medium") == medium_name and row.get("compound"):
114
+ comps.add(row["compound"].strip())
115
+ out = {}
116
+ for c in sorted(comps):
117
+ exid = "EX_" + c + "_e"
118
+ if exid in m.reactions:
119
+ out[exid] = default_lb
120
+ return out or _active_medium(m)
121
+
122
+
123
+ def build(input_spec, name=None, medium=None, venv=None, out_dir=None, progress_path=None,
124
+ engine="carveme"):
125
+ """主入口。engine:
126
+ carveme(默认): protein.faa -> carve(M9 gapfill) -> validate M9 -> 目标介质闭环
127
+ gapseq(M2) : genomic.fna -> WSL2 gapseq doall(30-60min,需探测 OK)-> 模型 -> 目标介质验证
128
+ """
129
+ if progress_path is None:
130
+ progress_path = os.path.join(tempfile.gettempdir(), "gem_build.progress.jsonl")
131
+ _log(progress_path, {"event": "build_start", "input": input_spec, "engine": engine})
132
+ if engine == "gapseq":
133
+ return _build_gapseq(input_spec, name=name, medium=medium, out_dir=out_dir,
134
+ progress_path=progress_path)
135
+ proteins = resolve_input(input_spec, progress_path, engine="carveme")
136
+ name = name or os.path.splitext(os.path.basename(proteins))[0]
137
+ out_dir = out_dir or MODEL_ROOT
138
+ os.makedirs(out_dir, exist_ok=True)
139
+ out_xml = os.path.join(out_dir, name + ".xml")
140
+
141
+ # 1) carve(自带 M9 gapfill,CarveMe 原生最小培养基)
142
+ st = time.time()
143
+ if not (os.path.exists(out_xml) and os.path.getmtime(out_xml) > os.path.getmtime(proteins)):
144
+ run_carve(proteins, out_xml, venv=venv, progress_path=progress_path,
145
+ gapfill_medium="M9")
146
+ else:
147
+ _log(progress_path, {"event": "carve_skip_cached"})
148
+
149
+ from silentio import silent_read_sbml
150
+ m1 = silent_read_sbml(out_xml)
151
+ med_m9 = _media_db_exchanges(m1, "M9") # 精确 M9 成分(非全开近似)
152
+
153
+ # 2) validate G1-G3:M9 介质(构建产物实测)
154
+ from validate import validate_model
155
+ rep_m9 = validate_model(out_xml, medium=med_m9, reference_growth=None)
156
+ g3_m9 = rep_m9["g3"]
157
+ _log(progress_path, {"event": "validate_m9", "overall": rep_m9["overall"],
158
+ "g3": g3_m9["status"], "growth": g3_m9.get("growth_medium"),
159
+ "exch": len(med_m9)})
160
+
161
+ # 3) 用户目标介质(可选):preset 展开 + resolve -> G3;FAIL 时 L1/L2 规则补洞闭环
162
+ target = None
163
+ user_rep = None
164
+ if medium:
165
+ from gapfind import resolve_medium, expand_medium
166
+ medium_exp, preset_used = expand_medium(medium)
167
+ resolved, unresolved = resolve_medium(m1, medium_exp)
168
+ _log(progress_path, {"event": "target_medium", "preset": preset_used,
169
+ "resolved": len(resolved), "unresolved": unresolved})
170
+ user_rep = validate_model(out_xml, medium=resolved, reference_growth=None)
171
+ g3_user = user_rep["g3"]
172
+ gapfixes = []
173
+ if g3_user["status"] == "FAIL" and resolved:
174
+ _log(progress_path, {"event": "gapfill_start"})
175
+ from gapfill import apply_fixes
176
+ gf = apply_fixes(out_xml, medium=resolved, max_add=20,
177
+ out=out_xml[:-4] + "_gf.xml")
178
+ gapfixes = gf.get("applied", [])
179
+ if gapfixes:
180
+ user_rep = validate_model(gf["out"], medium=resolved, reference_growth=None)
181
+ out_xml = gf["out"]
182
+ g3_user = user_rep["g3"]
183
+ _log(progress_path, {"event": "gapfill_done", "applied": len(gapfixes),
184
+ "g3_after": g3_user["status"],
185
+ "growth_after": g3_user.get("growth_medium")})
186
+ target = {
187
+ "medium": medium, "resolved_exchanges": len(resolved),
188
+ "unresolved": unresolved,
189
+ "g3": user_rep["g3"]["status"],
190
+ "growth": user_rep["g3"].get("growth_medium"),
191
+ "gapfixes_applied": len(gapfixes if 'gapfixes' in dir() else []),
192
+ }
193
+ # 若 M9 已 PASS 而用户介质 FAIL:诚实保留(模型可用介质=M9)
194
+ dt = round(time.time() - st, 1)
195
+ # 4) 模型卡(schema v2 起步:supported_mediums 由验证结果得出)
196
+ supported = [
197
+ {"medium_name": "M9", "ex_reactions": sorted(med_m9),
198
+ "growth_rate": g3_m9.get("growth_medium"), "units": "mmol/gDW/h",
199
+ "validation_status": "verified_G3" if g3_m9.get("status") == "PASS" else "unverified"},
200
+ ]
201
+ if target and target.get("g3") == "PASS":
202
+ tname = "custom"
203
+ if isinstance(medium, dict):
204
+ tname = medium.get("medium_name") or "custom"
205
+ supported.append({
206
+ "medium_name": tname, "ex_reactions": target.get("resolved_exchanges"),
207
+ "growth_rate": target.get("growth"), "units": "mmol/gDW/h",
208
+ "validation_status": "verified_G3_G4" if target.get("gapfixes_applied", 0) == 0 else "verified_G3_only",
209
+ })
210
+ # 模型卡(schema v2:init_card 统一基座 —— lineage v0.1.0 起始 + changelog=[build])
211
+ from model_card import init_card
212
+ card, card_path = init_card(
213
+ out_xml, name=name, engine="carveme", changelog_note="build",
214
+ engine_version=_carve_version(venv),
215
+ carve_cmd="carve INPUT -o OUT -g M9",
216
+ started=datetime.datetime.now().isoformat(timespec="seconds"),
217
+ elapsed_s=dt, model=out_xml,
218
+ validations_m9={k: rep_m9[k]["status"] for k in ("g1", "g2", "g3")},
219
+ growth_g3_m9=g3_m9.get("growth_medium"),
220
+ m9_exchanges=len(med_m9),
221
+ target=target,
222
+ supported_mediums=supported,
223
+ mapping={"protein_input": proteins})
224
+ _log(progress_path, {"event": "build_done", "model": out_xml, "card": card_path, "s": dt})
225
+ return {"model": out_xml, "card": card_path,
226
+ "validations_m9": card["validations_m9"],
227
+ "growth_g3_m9": card["growth_g3_m9"],
228
+ "target": target or {"note": "no target medium provided"},
229
+ "elapsed_s": dt}
230
+
231
+
232
+ def _build_gapseq(input_fna, name=None, medium=None, out_dir=None, progress_path=None):
233
+ """gapseq 引擎:WSL2 桥 doall(30-60min)→ 模型拷回 → 目标介质验证闭环 → 模型卡。"""
234
+ from gapseq_wsl import probe, run_gapseq
235
+ from silentio import silent_read_sbml
236
+ from validate import validate_model
237
+ from gapfind import expand_medium, resolve_medium
238
+
239
+ _log(progress_path, {"event": "gapseq_probe"})
240
+ p = probe()
241
+ if not p.get("capable"):
242
+ _log(progress_path, {"event": "gapseq_unavailable", "level": p.get("level"),
243
+ "detail": (p.get("detail") or "")[:300]})
244
+ raise RuntimeError(
245
+ f"gapseq 引擎不可用(level={p.get('level')}):{(p.get('detail') or '')[:300]}。"
246
+ "请先配置 WSL2 + gapseq 环境,或改用 carveme 引擎。")
247
+
248
+ input_fna = resolve_input(input_fna, progress_path, engine="gapseq")
249
+ name = name or os.path.splitext(os.path.basename(input_fna))[0]
250
+ out_dir = out_dir or MODEL_ROOT
251
+ os.makedirs(out_dir, exist_ok=True)
252
+ _log(progress_path, {"event": "gapseq_start", "note": "doall 30-60min,后台等待不误判超时"})
253
+ st = time.time()
254
+ model = None
255
+ try:
256
+ model, log_tail = run_gapseq(input_fna, out_dir, name=name,
257
+ progress=(lambda ev: _log(progress_path, ev)))
258
+ except Exception as e:
259
+ _log(progress_path, {"event": "gapseq_fail", "err": str(e)[:300]})
260
+ raise
261
+ dt = round(time.time() - st, 1)
262
+ _log(progress_path, {"event": "gapseq_done", "model": model, "s": dt})
263
+
264
+ # 目标介质验证(gapseq 模型用 AB 自然名/用户 medium;无 medium 时 M9 兜底)
265
+ target = None
266
+ med_exp, preset_used = expand_medium(medium) if medium else ({}, None)
267
+ if medium:
268
+ m1 = silent_read_sbml(model)
269
+ resolved, unresolved = resolve_medium(m1, med_exp)
270
+ rep = validate_model(model, medium=resolved, reference_growth=None)
271
+ g3 = rep["g3"]
272
+ gapfixes = []
273
+ if g3["status"] == "FAIL" and resolved:
274
+ _log(progress_path, {"event": "gapseq_gapfill_start"})
275
+ from gapfill import apply_fixes
276
+ gf = apply_fixes(model, medium=resolved, max_add=20,
277
+ out=model[:-4] + "_gf.xml")
278
+ gapfixes = gf.get("applied", [])
279
+ if gapfixes:
280
+ model = gf["out"]
281
+ rep = validate_model(model, medium=resolved, reference_growth=None)
282
+ g3 = rep["g3"]
283
+ _log(progress_path, {"event": "gapseq_gapfill_done", "applied": len(gapfixes),
284
+ "g3_after": g3["status"]})
285
+ target = {"medium": medium, "preset": preset_used, "g3": g3["status"],
286
+ "growth": g3.get("growth_medium"), "unresolved": unresolved,
287
+ "gapfixes_applied": len(gapfixes)}
288
+ else:
289
+ _log(progress_path, {"event": "gapseq_no_medium"})
290
+ rep = None
291
+
292
+ from model_card import init_card
293
+ card, card_path = init_card(
294
+ model, name=name, engine="gapseq", changelog_note="build",
295
+ engine_version=p.get("gapseq_version"),
296
+ gapseq_probe=p.get("level"),
297
+ started=datetime.datetime.now().isoformat(timespec="seconds"),
298
+ elapsed_s=dt, model=model,
299
+ validations={k: rep[k]["status"] for k in ("g1", "g2", "g3")} if rep else None,
300
+ growth_g3=rep["g3"].get("growth_medium") if rep else None,
301
+ target=target,
302
+ mapping={"genome_input": input_fna})
303
+ _log(progress_path, {"event": "build_done", "model": model, "card": card_path, "s": dt})
304
+ return {"model": model, "card": card_path,
305
+ "validations": card["validations"], "growth_g3": card["growth_g3"],
306
+ "target": target or {"note": "no target medium provided"},
307
+ "engine": "gapseq", "elapsed_s": dt}
308
+
309
+
310
+ def _carve_version(venv=None):
311
+ try:
312
+ venv = venv or DEFAULT_CARVE_VENV
313
+ py = os.path.join(venv, "Scripts", "python.exe")
314
+ r = subprocess.run([py, "-c",
315
+ "import importlib.metadata as im; print(im.version('carveme'))"],
316
+ capture_output=True, text=True, timeout=60)
317
+ return (r.stdout or "").strip() or "unknown"
318
+ except Exception:
319
+ return "unknown"
320
+
321
+
322
+ if __name__ == "__main__":
323
+ import argparse
324
+ ap = argparse.ArgumentParser()
325
+ ap.add_argument("--input", required=True)
326
+ ap.add_argument("--name")
327
+ ap.add_argument("--engine", default="carveme", choices=["carveme", "gapseq"])
328
+ ap.add_argument("--medium-json")
329
+ ap.add_argument("--out-dir")
330
+ ap.add_argument("--progress")
331
+ a = ap.parse_args()
332
+ medium = json.loads(a.medium_json) if a.medium_json else None
333
+ try:
334
+ res = build(a.input, name=a.name, medium=medium, out_dir=a.out_dir,
335
+ progress_path=a.progress, engine=a.engine)
336
+ print(json.dumps({"ok": True, "result": res}, ensure_ascii=False))
337
+ except Exception as e:
338
+ import traceback
339
+ sys.stderr.write("Traceback (most recent call last):\n")
340
+ traceback.print_exc(file=sys.stderr)
341
+ print(json.dumps({"ok": True, "result": None,
342
+ "error_hint": f"build failed: {type(e).__name__}: {e}"},
343
+ ensure_ascii=False))
@@ -0,0 +1,127 @@
1
+ # build_whitelist.py — B' 白名单 B0/B1:gapseq rxn 库 -> 反应白名单(本地基准,license 守则:不分发)
2
+ # B0: 聚合 rxn/*.fasta(非空)→ rxn_proteins.fa + mapping(序列header -> 反应ID)
3
+ # B1: diamond makedb + blastp 目标物种 faa → 命中反应集(EVIDENCE_sequence 候选池)
4
+ import os
5
+ import sys
6
+ import time
7
+
8
+ SEQDB = os.environ.get("GEM_GAPSEQ_DB", r"F:\Datasets\gapseq\db\Bacteria")
9
+ DIAMOND = r"C:\Users\shuai\.dsh\dsh-bio-gem\venv-carveme\Scripts\diamond.exe"
10
+
11
+
12
+ def build_rxn_fasta(out_fa, out_map=None, min_size=100):
13
+ """聚合 rxn/ 非空文件(文件名=反应 ID)→ 序列 fasta + mapping。返回 (seqs, files_used)。"""
14
+ rxn_dir = os.path.join(SEQDB, "rxn")
15
+ n_seq = 0
16
+ n_file = 0
17
+ with open(out_fa, "w", encoding="utf-8", errors="ignore") as fo:
18
+ if out_map:
19
+ fm = open(out_map, "w", encoding="utf-8")
20
+ fm.write("reaction_id\tseq_id\n")
21
+ for fn in os.listdir(rxn_dir):
22
+ if not fn.endswith(".fasta"):
23
+ continue
24
+ p = os.path.join(rxn_dir, fn)
25
+ if os.path.getsize(p) < min_size:
26
+ continue
27
+ rxn_id = fn[:-6] # 文件名去 .fasta -> 反应 ID
28
+ with open(p, encoding="utf-8", errors="ignore") as f:
29
+ cur = None
30
+ buf = []
31
+ for line in f:
32
+ line = line.rstrip("\n")
33
+ if line.startswith(">"):
34
+ if cur is not None:
35
+ fo.write(f">{cur}\n{''.join(buf)}\n")
36
+ if out_map:
37
+ fm.write(f"{rxn_id}\t{cur}\n")
38
+ n_seq += 1
39
+ cur = f"{rxn_id}|{line[1:].split()[0]}"
40
+ buf = []
41
+ elif line.strip():
42
+ buf.append(line.strip())
43
+ if cur is not None and buf:
44
+ fo.write(f">{cur}\n{''.join(buf)}\n")
45
+ if out_map:
46
+ fm.write(f"{rxn_id}\t{cur}\n")
47
+ n_seq += 1
48
+ n_file += 1
49
+ if out_map:
50
+ fm.close()
51
+ return n_seq, n_file
52
+
53
+
54
+ def diamond_whitelist(faa, out_dir, db_path=None, rxn_fa=None, out_tsv=None,
55
+ evalue=1e-5, min_bitscore=60, max_target_seqs=5):
56
+ """B1: diamond blastp 目标物种 faa vs rxn_all 数据库 -> 命中反应集。
57
+ db_path 缺省用 out_dir/rxn_all.dmnd(无则由 rxn_fa 建,rxn_fa 再缺则用 GEM_GAPSEQ_DB 的 B0 产物)。
58
+ 返回 {"rxn_hits": {rxn_id: [seq_hit_desc...]}, "n_hits": N, "hits_tsv": path, "db": path}。
59
+ License 守则: rxn 库/命中集仅本地使用,不进 git/发布包(调用方负责落在 ~/.dsh 下)。"""
60
+ import subprocess
61
+ os.makedirs(out_dir, exist_ok=True)
62
+ if not os.path.exists(DIAMOND):
63
+ raise FileNotFoundError(f"diamond not found: {DIAMOND}")
64
+ if db_path is None:
65
+ db_path = os.path.join(out_dir, "rxn_all.dmnd")
66
+ if rxn_fa is None:
67
+ rxn_fa = os.path.join(out_dir, "rxn_all.fa")
68
+ if not os.path.exists(db_path):
69
+ if not os.path.exists(rxn_fa):
70
+ # B0 现场聚合(SEQDB rxn/ 目录 -> rxn_all.fa)
71
+ n_seq, n_file = build_rxn_fasta(rxn_fa)
72
+ if n_seq == 0:
73
+ raise FileNotFoundError(f"no rxn fasta built from {SEQDB}/rxn (GEM_GAPSEQ_DB?)")
74
+ rc, so, se, secs = subprocess_run([DIAMOND, "makedb", "--in", rxn_fa, "--db", db_path])
75
+ if rc != 0:
76
+ raise RuntimeError(f"diamond makedb failed rc={rc}: {se}")
77
+ if out_tsv is None:
78
+ out_tsv = os.path.join(out_dir, os.path.splitext(os.path.basename(faa))[0] + "_hits.tsv")
79
+ rc, so, se, secs = subprocess_run(
80
+ [DIAMOND, "blastp", "-d", db_path, "-q", faa, "-o", out_tsv,
81
+ "--evalue", str(evalue), "--max-target-seqs", str(max_target_seqs),
82
+ "--outfmt", "6", "qseqid", "sseqid", "pident", "evalue", "bitscore"],
83
+ timeout=3600)
84
+ if rc != 0:
85
+ raise RuntimeError(f"diamond blastp failed rc={rc}: {se}")
86
+ rxn_hits = {}
87
+ with open(out_tsv, encoding="utf-8", errors="ignore") as f:
88
+ for line in f:
89
+ p = line.rstrip("\r\n").split("\t")
90
+ if len(p) < 5:
91
+ continue
92
+ qseqid, sseqid = p[0], p[1]
93
+ try:
94
+ if float(p[4]) < min_bitscore:
95
+ continue
96
+ except ValueError:
97
+ continue
98
+ rxn_id = sseqid.split("|")[0].strip() # header 约定: RXNID|uniprot...
99
+ if not rxn_id:
100
+ continue
101
+ rxn_hits.setdefault(rxn_id, [])
102
+ if qseqid not in rxn_hits[rxn_id]:
103
+ rxn_hits[rxn_id].append(qseqid)
104
+ return {"rxn_hits": rxn_hits, "n_hits": len(rxn_hits),
105
+ "hits_tsv": out_tsv, "db": db_path, "evalue": evalue,
106
+ "min_bitscore": min_bitscore}
107
+
108
+
109
+ def subprocess_run(cmd, timeout=3600):
110
+ import subprocess
111
+ st = time.time()
112
+ r = subprocess.run(cmd, capture_output=True, timeout=timeout)
113
+ return r.returncode, (r.stdout or b"").decode("utf-8", "ignore")[-800:], (r.stderr or b"").decode("utf-8", "ignore")[-800:], round(time.time() - st, 1)
114
+
115
+
116
+ if __name__ == "__main__":
117
+ import json
118
+ fa = sys.argv[1] if len(sys.argv) > 1 else r"D:\Program\hermes\temp\gem_whitelist\rxn_all.fa"
119
+ out_dir = os.path.dirname(fa) or "."
120
+ os.makedirs(out_dir, exist_ok=True)
121
+ map_p = os.path.join(out_dir, "rxn_map.tsv")
122
+ t0 = time.time()
123
+ n_seq, n_file = build_rxn_fasta(fa, map_p)
124
+ print(json.dumps({"seqs": n_seq, "files_used": n_file, "out_fa": fa,
125
+ "map": map_p, "elapsed_s": round(time.time() - t0, 1)},
126
+ ensure_ascii=False, indent=2))
127
+ print(f"文件大小: {os.path.getsize(fa)/1e6:.1f} MB")
@@ -0,0 +1,201 @@
1
+ # double_knockout.py — 阶段C-C2 双敲 v1(合成致死预测,L2 非平凡)
2
+ # 候选池(预算可控):① GPR 结构先验——纯 or 型且恰 2 基因的反应 = 穷尽型同工酶对(廉价必做);
3
+ # ② FVA 预筛活性反应关联基因中"共享反应"的基因对(复用 essential_scan.prescreen_candidates +
4
+ # scan_essentiality;全扫受 max_pairs 预算上限,默认 5000,超限截断+报告)。
5
+ # 判定:单敲双活(>EPS)且双敲死(<=EPS)→ 合成致死对。单敲生长值按对惰性计算并缓存。
6
+ # 假设声明(方案文件要求,内置于输出与 description):细菌双敲验证率无大规模实验数据支撑,
7
+ # 本结果=假设生成,供实验设计参考非结论。
8
+ # 退化护栏(阶段 A/B 教训):wt<=EPS(介质下不生长)→ 不扫描不登记账本,degenerate:true + 介质适配提示。
9
+ import os
10
+ import re
11
+ import sys
12
+ import csv
13
+ import time
14
+ import json
15
+ from itertools import combinations
16
+
17
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
18
+
19
+ from silentio import silent_read_sbml
20
+ from essential_scan import setup_model_medium, scan_essentiality, EPS
21
+
22
+ ASSUMPTION_NOTE = "细菌双敲验证率无大规模实验数据支撑,本结果=假设生成,供实验设计参考非结论"
23
+
24
+
25
+ def _gpr_or_pairs(m):
26
+ """穷尽型 or 同工酶对:纯 or GPR 且恰 2 个不同基因的反应。返回去重 {(a,b): [rxn...]}(a<b)。"""
27
+ out = {}
28
+ for r in m.reactions:
29
+ gpr = str(r.gpr or "").strip()
30
+ if not gpr or " and " in gpr.lower():
31
+ continue
32
+ genes = sorted({g.strip() for g in re.split(r"\bor\b", gpr, flags=re.I) if g.strip()})
33
+ if len(genes) == 2:
34
+ out.setdefault((genes[0], genes[1]), []).append(r.id)
35
+ return out
36
+
37
+
38
+ def _double_growth(m, ga, gb):
39
+ with m:
40
+ m.genes.get_by_id(ga).knock_out()
41
+ m.genes.get_by_id(gb).knock_out()
42
+ v = m.optimize().objective_value
43
+ return round(float(v), 6) if (v is not None and v == v) else 0.0
44
+
45
+
46
+ def _single_growth(m, gid):
47
+ with m:
48
+ m.genes.get_by_id(gid).knock_out()
49
+ v = m.optimize().objective_value
50
+ return round(float(v), 6) if (v is not None and v == v) else 0.0
51
+
52
+
53
+ def double_knockout(model_path, medium=None, max_pairs=5000, export_csv=None,
54
+ ledger_refs=True, ledger_path=None, progress=None):
55
+ log = progress or (lambda s: sys.stderr.write(str(s) + "\n"))
56
+ medium = medium or {"medium_name": "AB"}
57
+ t0 = time.time()
58
+ m = silent_read_sbml(model_path)
59
+ resolved, unresolved, preset = setup_model_medium(m, medium)
60
+ with m:
61
+ wt = m.optimize().objective_value
62
+ wt = round(float(wt), 6) if wt is not None else 0.0
63
+ log(f"[dk] {model_path} medium={medium} wt={wt} max_pairs={max_pairs}")
64
+
65
+ out = {"model": model_path, "medium": medium, "medium_preset": preset,
66
+ "wt_growth": wt, "units": "mmol/gDW/h", "assumption_note": ASSUMPTION_NOTE,
67
+ "max_pairs": max_pairs, "eps": EPS,
68
+ "degenerate": wt <= EPS}
69
+ if out["degenerate"]:
70
+ out["degenerate_note"] = (f"wt_growth={wt}<=EPS:被测模型在指定介质下不生长,双敲判定无意义,"
71
+ "未扫描、未登记账本。提示:内置介质预设为根瘤菌科(C58)调校,"
72
+ "非根瘤菌模型需先做介质适配(阶段B-B3 molybdate 教训)。")
73
+ try:
74
+ from benchmark import medium_adaptation_hints
75
+ out["medium_adaptation_hints"] = medium_adaptation_hints(model_path, medium)
76
+ except Exception as e:
77
+ sys.stderr.write(f"[dk] hints WARN: {type(e).__name__}: {e}" + "\n")
78
+ log(f"[dk] DEGENERATE wt={wt} <= EPS:不扫描不登记")
79
+ return out
80
+
81
+ # 1) 全量必需性扫描(复用 essential_scan 核心):essential 集 + FVA 活性候选基因
82
+ t_scan = time.time()
83
+ scan = scan_essentiality(m, return_candidates=True)
84
+ essential = set(scan["essential_genes"])
85
+ alive = set(scan["candidate_genes"]) - essential
86
+ log(f"[dk] scan done ({round(time.time()-t_scan,1)}s): tested={scan['tested_genes']} "
87
+ f"essential={len(essential)} alive_singles={len(alive)}")
88
+
89
+ # 2) 候选池
90
+ prior = _gpr_or_pairs(m)
91
+ pool = [] # (a, b, rationale)
92
+ seen = set()
93
+ for (a, b), rxns in sorted(prior.items()):
94
+ if a in alive and b in alive:
95
+ pool.append((a, b, f"GPR先验(穷尽型or同工酶,反应:{','.join(sorted(rxns)[:3])})"))
96
+ seen.add((a, b))
97
+ n_prior = len(pool)
98
+ # ② 共享反应的存活基因对(E4c 先验排序:共享活性反应数降序——冗余暴露越多 SL 先验越高,
99
+ # 字母序破平;max_pairs 截断从"字母序前 N"变为"先验驱动 top-N")
100
+ budget = max_pairs - n_prior
101
+ scan_pairs_total = 0
102
+ active_rxns = scan.get("active_rxn_ids") or []
103
+ if budget > 0:
104
+ shared_count = {}
105
+ for rid in sorted(active_rxns):
106
+ gset = sorted(g for g in (x.id for x in m.reactions.get_by_id(rid).genes)
107
+ if g in alive)
108
+ for a, b in combinations(gset, 2):
109
+ if (a, b) not in seen:
110
+ shared_count[(a, b)] = shared_count.get((a, b), 0) + 1
111
+ shared = sorted(shared_count, key=lambda p: (-shared_count[p], p[0], p[1]))
112
+ scan_pairs_total = len(shared)
113
+ for a, b in shared[:budget]:
114
+ pool.append((a, b, f"全扫(共享活性反应×{shared_count[(a, b)]})"))
115
+ truncated = scan_pairs_total > max(0, budget)
116
+ log(f"[dk] pool: prior={n_prior} scan_total={scan_pairs_total} "
117
+ f"scan_tested={min(scan_pairs_total, max(0, budget))} truncated={truncated}")
118
+
119
+ # 3) 逐对判定:双敲 LP;SL -> 补算两个单敲值(缓存)
120
+ results = []
121
+ singles = {}
122
+ tested = 0
123
+ for a, b, rationale in pool:
124
+ tested += 1
125
+ gd = _double_growth(m, a, b)
126
+ if gd <= EPS:
127
+ for g in (a, b):
128
+ if g not in singles:
129
+ singles[g] = _single_growth(m, g)
130
+ results.append({"pair": [a, b], "single_a_growth": singles[a],
131
+ "single_b_growth": singles[b], "double_growth": gd,
132
+ "rationale": rationale, "source": "gem_double_knockout"})
133
+ log(f"[dk] SL {a} x {b} (double={gd}, {rationale[:24]})")
134
+ if tested % 500 == 0:
135
+ log(f"[dk] tested {tested}/{len(pool)}, SL so far {len(results)}")
136
+
137
+ # 4) 账本登记(每对一条 type=synthetic_lethal;幂等)
138
+ reg = None
139
+ if ledger_refs and results:
140
+ try:
141
+ import ledger as _ledger
142
+ from model_card import load_card as _load_card
143
+ lineage_v = ((_load_card(model_path) or {}).get("model_lineage") or {}).get("version")
144
+ cond = preset or (f"custom({len(resolved)} EX)" if resolved else "unspecified")
145
+ pair_rows = [{"gene_a": r["pair"][0], "gene_b": r["pair"][1]} for r in results]
146
+ reg = _ledger.register_synthetic_lethal(model_path, pair_rows, condition=cond,
147
+ lineage_version=lineage_v, path=ledger_path)
148
+ log(f"[dk] ledger: appended={reg['appended']} skipped={reg['skipped_duplicates']}")
149
+ except Exception as e:
150
+ sys.stderr.write(f"[dk] ledger registration WARN: {type(e).__name__}: {e}\n")
151
+
152
+ out.update({
153
+ "genes_tested_single": scan["tested_genes"],
154
+ "essential_count": len(essential),
155
+ "alive_singles": len(alive),
156
+ "pairs_tested": tested,
157
+ "pairs_found": len(results),
158
+ "scan_ordering": "shared_rxn_count_desc_then_alpha (E4c)",
159
+ "budget": {"prior_pairs": n_prior, "scan_pairs_total": scan_pairs_total,
160
+ "scan_pairs_tested": min(scan_pairs_total, max(0, budget)),
161
+ "truncated": truncated},
162
+ "results": results,
163
+ "timing_seconds": round(time.time() - t0, 1),
164
+ })
165
+ if reg is not None:
166
+ out["ledger_registration"] = reg
167
+ if export_csv:
168
+ n = _export_csv(export_csv, results, out)
169
+ out["export_csv"] = export_csv
170
+ out["export_csv_rows"] = n
171
+ out["export_csv_bytes"] = os.path.getsize(export_csv)
172
+ log(f"[dk] CSV {export_csv}: {n} rows")
173
+ return out
174
+
175
+
176
+ def _export_csv(path, results, out):
177
+ n = 0
178
+ with open(path, "w", newline="", encoding="utf-8-sig") as f:
179
+ w = csv.writer(f)
180
+ w.writerow(["# assumption", out["assumption_note"]])
181
+ w.writerow(["gene_a", "gene_b", "single_a_growth", "single_b_growth",
182
+ "double_growth", "rationale", "source"])
183
+ for r in results:
184
+ w.writerow([r["pair"][0], r["pair"][1], r["single_a_growth"],
185
+ r["single_b_growth"], r["double_growth"], r["rationale"], r["source"]])
186
+ n += 1
187
+ return n
188
+
189
+
190
+ if __name__ == "__main__":
191
+ args = {}
192
+ if len(sys.argv) > 1:
193
+ with open(sys.argv[1], encoding="utf-8") as f:
194
+ args = json.load(f)
195
+ elif not sys.stdin.isatty():
196
+ args = json.loads(sys.stdin.read())
197
+ a = args.get("args", args)
198
+ print(json.dumps({"ok": True, "result": double_knockout(
199
+ a.get("model"), medium=a.get("medium"), max_pairs=a.get("max_pairs", 5000),
200
+ export_csv=a.get("export_csv"), ledger_refs=a.get("ledger_refs", True),
201
+ ledger_path=a.get("ledger_path"))}, ensure_ascii=False))