@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,182 @@
1
+ # enrichment.py — 阶段C-C3 必需基因通路富集(超几何 + BH FDR;菌种通用)
2
+ # 通路注释源(C3 开工探索结论):gapseq 模型在 SBML groups 里写入 MetaCyc 通路分组
3
+ # (C58 实测 1051 个 PWY groups,9942 个反应成员关系);BiGG 静态下载模型无 groups
4
+ # (iML1515 实测 0)——无 groups 时按契约返回 annotation_unavailable 兜底(不伪造通路)。
5
+ # gene_list 缺省从账本读该模型 essentiality 预测的基因。不登记账本(统计描述非单条预测)。
6
+ import os
7
+ import sys
8
+ import csv
9
+ import time
10
+ import json
11
+ from math import comb
12
+
13
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
14
+
15
+ from silentio import silent_read_sbml
16
+
17
+ NOTE_UNAVAILABLE = ("模型无 SBML groups(通路)注释。可补途径:①用 gapseq 重建(自带 MetaCyc pathway "
18
+ "groups);②从 BiGG API 取模型 subsystem 后注入 groups;③按 ec-code 注释做粗分类。")
19
+
20
+
21
+ def _hypergeom_sf(x, N, K, n):
22
+ """P(X >= x),X~Hypergeom(N, K, n)。scipy 优先,缺失回退精确求和。"""
23
+ try:
24
+ from scipy.stats import hypergeom
25
+ return float(hypergeom.sf(x - 1, N, K, n))
26
+ except Exception:
27
+ lo, hi = max(x, 0), min(K, n)
28
+ if hi < lo:
29
+ return 1.0
30
+ total = comb(N, n)
31
+ s = sum(comb(K, i) * comb(N - K, n - i) for i in range(lo, hi + 1))
32
+ return min(1.0, s / total) if total else 1.0
33
+
34
+
35
+ def _bh_fdr(pvals):
36
+ """Benjamini-Hochberg FDR 校正(单调保序)。"""
37
+ m = len(pvals)
38
+ if m == 0:
39
+ return []
40
+ order = sorted(range(m), key=lambda i: pvals[i])
41
+ fdr = [0.0] * m
42
+ prev = 1.0
43
+ for rank, i in enumerate(reversed(order), 1):
44
+ idx = m - rank + 1 # 从大到小累计 min
45
+ v = min(prev, pvals[i] * m / (m - rank + 1))
46
+ fdr[i] = v
47
+ prev = v
48
+ return fdr
49
+
50
+
51
+ def _pathway_gene_sets(m):
52
+ """SBML groups -> {pathway_name: gene set}(成员反应的 GPR 基因并集)。"""
53
+ out = {}
54
+ for g in getattr(m, "groups", []) or []:
55
+ genes = set()
56
+ for member in g.members:
57
+ try:
58
+ for gene in member.genes:
59
+ genes.add(gene.id)
60
+ except Exception:
61
+ continue
62
+ if genes:
63
+ out[g.name or g.id] = genes
64
+ return out
65
+
66
+
67
+ def _ledger_essential_genes(model_path, ledger_path):
68
+ import ledger as _ledger
69
+ if not ledger_path:
70
+ ledger_path = _ledger.model_ledger_path(model_path) # 2026-08-31:默认=该模型自己的账本
71
+ rows, corrupt = _ledger.load_rows(ledger_path)
72
+ genes = []
73
+ for r in rows:
74
+ # 单模型账本语义:账本里都是该模型(basename 组,含路径变体)的预测,不再精确匹配 model
75
+ if r.get("type") == "essentiality":
76
+ gid = (r.get("content") or "").split(" 在 ")[0].strip()
77
+ if gid:
78
+ genes.append(gid)
79
+ return sorted(set(genes)), len(rows), corrupt
80
+
81
+
82
+ def enrichment(model_path, gene_list=None, pathway_source=None, ledger_path=None,
83
+ export_csv=None, progress=None):
84
+ log = progress or (lambda s: sys.stderr.write(str(s) + "\n"))
85
+ t0 = time.time()
86
+ m = silent_read_sbml(model_path)
87
+
88
+ pw_sets = _pathway_gene_sets(m) if (pathway_source or "groups") == "groups" else {}
89
+ if not pw_sets:
90
+ # 注释缺失兜底(必须):不伪造通路
91
+ return {
92
+ "model": model_path, "annotation_unavailable": True,
93
+ "note": NOTE_UNAVAILABLE,
94
+ "groups_found": len(getattr(m, "groups", []) or []),
95
+ "ec_code_reactions": sum(1 for r in m.reactions
96
+ if (r.annotation or {}).get("ec-code")),
97
+ "gene_list_provided": bool(gene_list),
98
+ "timing_seconds": round(time.time() - t0, 1),
99
+ }
100
+
101
+ # gene_list:显式传入优先;缺省从账本 essentiality 预测读取(本模型)
102
+ source_note = None
103
+ if not gene_list:
104
+ gene_list, total_rows, corrupt = _ledger_essential_genes(model_path, ledger_path)
105
+ source_note = (f"gene_list 缺省来自账本 essentiality 预测(model 精确匹配 {model_path},"
106
+ f"账本总行 {total_rows},corrupt {corrupt})")
107
+ log(f"[enrich] gene_list from ledger: {len(gene_list)} genes")
108
+ gene_list = sorted(set(gene_list or []))
109
+
110
+ # 背景 = 模型内所有有 GPR 关联的基因(可被通路注释到的全体基因)
111
+ bg_genes = {g.id for g in m.genes if g.reactions}
112
+ input_in_bg = [g for g in gene_list if g in bg_genes]
113
+ N, n = len(bg_genes), len(input_in_bg)
114
+
115
+ rows = []
116
+ for pw_name, pw_genes in pw_sets.items():
117
+ bg_hit = pw_genes & bg_genes
118
+ if not bg_hit:
119
+ continue
120
+ K = len(bg_hit)
121
+ hits = sorted(set(input_in_bg) & bg_hit)
122
+ x = len(hits)
123
+ if x == 0:
124
+ continue
125
+ p = _hypergeom_sf(x, N, K, n)
126
+ exp = K * n / N if N else 0.0
127
+ rows.append({"pathway": pw_name, "genes_hit": hits, "genes_hit_count": x,
128
+ "background_hit": K, "pathway_size": len(pw_genes),
129
+ "total_bg": N, "n_input": n, "expected_count": round(exp, 3),
130
+ "fold_enrichment": round(x / exp, 3) if exp > 0 else None,
131
+ "p_value": p})
132
+ fdrs = _bh_fdr([r["p_value"] for r in rows])
133
+ for r, f in zip(rows, fdrs):
134
+ r["fdr"] = f
135
+ rows.sort(key=lambda r: (r["p_value"], r["pathway"]))
136
+ sig = [r for r in rows if r["fdr"] <= 0.05]
137
+ log(f"[enrich] pathways tested={len(rows)} significant(FDR<=0.05)={len(sig)}")
138
+
139
+ out = {
140
+ "model": model_path,
141
+ "pathway_source": "sbml_groups(MetaCyc PWY)",
142
+ "annotation_unavailable": False,
143
+ "gene_list_source": source_note or "explicit",
144
+ "gene_list": gene_list,
145
+ "background_size": N,
146
+ "n_input": n,
147
+ "n_too_small": n < 10,
148
+ "n_too_small_note": (f"有效映射基因 n={n} < 10,统计功效有限,结果仅供方向参考" if n < 10 else None),
149
+ "pathways_tested": len(rows),
150
+ "significant_count_fdr05": len(sig),
151
+ "results": rows,
152
+ "multiple_testing": "hypergeom 单侧富集 + Benjamini-Hochberg FDR",
153
+ "timing_seconds": round(time.time() - t0, 1),
154
+ }
155
+ if export_csv:
156
+ with open(export_csv, "w", newline="", encoding="utf-8-sig") as f:
157
+ w = csv.writer(f)
158
+ w.writerow(["pathway", "genes_hit_count", "genes_hit", "background_hit",
159
+ "pathway_size", "total_bg", "n_input", "expected_count",
160
+ "fold_enrichment", "p_value", "fdr"])
161
+ for r in rows:
162
+ w.writerow([r["pathway"], r["genes_hit_count"], ";".join(r["genes_hit"]),
163
+ r["background_hit"], r["pathway_size"], r["total_bg"],
164
+ r["n_input"], r["expected_count"], r["fold_enrichment"],
165
+ r["p_value"], r["fdr"]])
166
+ out["export_csv"] = export_csv
167
+ out["export_csv_rows"] = len(rows)
168
+ log(f"[enrich] CSV {export_csv}: {len(rows)} rows")
169
+ return out
170
+
171
+
172
+ if __name__ == "__main__":
173
+ args = {}
174
+ if len(sys.argv) > 1:
175
+ with open(sys.argv[1], encoding="utf-8") as f:
176
+ args = json.load(f)
177
+ elif not sys.stdin.isatty():
178
+ args = json.loads(sys.stdin.read())
179
+ a = args.get("args", args)
180
+ print(json.dumps({"ok": True, "result": enrichment(
181
+ a.get("model"), gene_list=a.get("gene_list"), pathway_source=a.get("pathway_source"),
182
+ ledger_path=a.get("ledger_path"), export_csv=a.get("export_csv"))}, ensure_ascii=False))
@@ -0,0 +1,195 @@
1
+ # essential_scan.py — G5 全量必需性扫描(路线 P0:FVA 预筛 + 手工敲除)
2
+ # 流程: 介质设置 -> FVA(全范围) 预筛可通量基因(死基因免敲)-> 手工单基因敲除
3
+ # -> 必需基因列表 + 模型卡章节数据(证据分级配色字段)
4
+ # Windows 纪律: FVA processes=1(无 fork);手工敲除循环;GLPK 快速线性
5
+ # 阶段A-M2: 介质 setup 与扫描核心拆为 setup_model_medium/scan_essentiality 供 sensitivity 复用
6
+ # (行为不变,C58 AB 必需 155 锚点必须原样复现)
7
+ import os
8
+ import sys
9
+ import time
10
+ import cobra
11
+ from cobra.flux_analysis import flux_variability_analysis
12
+
13
+ from silentio import silent_read_sbml
14
+ from gapfind import expand_medium, resolve_medium
15
+
16
+ EPS = 1e-6
17
+ FVA_EPS = 1e-9
18
+
19
+
20
+ def setup_model_medium(m, medium=None):
21
+ """介质 setup(对齐 validate G3):expand_medium -> 全交换清零 -> resolve_medium 设 bounds。
22
+ 阶段A-M2 抽出:essential_scan 与 sensitivity 共用同一介质口径。返回 (resolved, unresolved, preset)。"""
23
+ med, preset = expand_medium(medium) if medium else ({}, None)
24
+ resolved, unresolved = resolve_medium(m, med) if med else ({}, [])
25
+ for r in m.reactions:
26
+ if r.id.startswith(("EX_", "DM_", "SK_")) or r.boundary:
27
+ r.lower_bound = 0.0
28
+ for exid, lb in resolved.items():
29
+ if exid in m.reactions:
30
+ m.reactions.get_by_id(exid).lower_bound = lb
31
+ return resolved, unresolved, preset
32
+
33
+
34
+ def prescreen_candidates(m, gene_subset=None):
35
+ """FVA(fraction=0) 预筛(阶段C-C2 抽出复用):返回 (active_rxns, cand_genes, n_tested, fva_s)。
36
+ cand_genes = 关联至少一个可通量反应的基因(死基因免敲);n_tested = FVA 检查的反应总数。"""
37
+ t0 = time.time()
38
+ fva = flux_variability_analysis(m, fraction_of_optimum=0.0, processes=1)
39
+ fva_s = round(time.time() - t0, 1)
40
+ active_rxns = set(fva.index[((fva["maximum"] > FVA_EPS) | (fva["minimum"] < -FVA_EPS))])
41
+ cand_genes = set()
42
+ for g in m.genes:
43
+ if gene_subset and g.id not in gene_subset:
44
+ continue
45
+ if any(r.id in active_rxns for r in g.reactions):
46
+ cand_genes.add(g.id)
47
+ print(f"[scan] FVA {fva_s}s;可通量反应 {len(active_rxns)}/{len(fva)};候选基因 {len(cand_genes)}",
48
+ file=sys.stderr)
49
+ return active_rxns, cand_genes, len(fva), fva_s
50
+
51
+
52
+ def scan_essentiality(m, gene_subset=None, progress=None, return_candidates=False):
53
+ """必需性扫描核心(阶段A-M2 抽出复用)。输入:介质 bounds 已设好的模型。
54
+ FVA(fraction=0) 预筛 -> 可通量反应关联基因 -> 手工敲除(<EPS 判必需)。
55
+ 返回 {wt_growth, total_genes, fva_tested_reactions, active_reactions, tested_genes,
56
+ essential_count, essential_genes, organs, fva_seconds, knock_seconds}。"""
57
+ with m:
58
+ wt = m.optimize().objective_value
59
+ print(f"[scan] wt = {wt:.6f}; genes = {len(m.genes)}", file=sys.stderr)
60
+
61
+ # 1) FVA 预筛(fraction_of_optimum=0 全范围):无通量的反应关联基因免敲
62
+ active_rxns, cand_genes, n_fva, fva_s = prescreen_candidates(m, gene_subset=gene_subset)
63
+ print(f"[scan] 候选基因(关联可通量反应){len(cand_genes)}", file=sys.stderr)
64
+ print(f"[scan] 预计免敲 {max(0, len(m.genes) - len(cand_genes))} 个({max(0, len(m.genes)-len(cand_genes))/max(1,len(m.genes))*100:.0f}%)", file=sys.stderr)
65
+
66
+ # 2) 手工敲除循环(HANDOFF-03:不用 single_gene_deletion)
67
+ t0 = time.time()
68
+ essential = []
69
+ for gid in sorted(cand_genes):
70
+ with m:
71
+ m.genes.get_by_id(gid).knock_out()
72
+ v = m.optimize().objective_value
73
+ if v < EPS:
74
+ essential.append(gid)
75
+ if progress and len(essential) % 50 == 0:
76
+ progress({"tested": len(cand_genes), "essential_so_far": len(essential)})
77
+ knock_s = round(time.time() - t0, 1)
78
+
79
+ out = {
80
+ "wt_growth": round(wt, 6),
81
+ "total_genes": len(m.genes),
82
+ "fva_tested_reactions": n_fva,
83
+ "active_reactions": len(active_rxns),
84
+ "tested_genes": len(cand_genes),
85
+ "essential_count": len(essential),
86
+ "essential_genes": essential,
87
+ "organs": {"essential_count": len(essential), "viable_ratio": round(1 - len(essential)/max(1, len(cand_genes)), 4)},
88
+ "fva_seconds": fva_s,
89
+ "knock_seconds": knock_s,
90
+ }
91
+ if return_candidates: # 阶段C-C2:double_knockout 复用(避免二次 FVA 预筛)
92
+ out["candidate_genes"] = sorted(cand_genes)
93
+ out["active_rxn_ids"] = sorted(active_rxns)
94
+ return out
95
+
96
+
97
+ def _load_gene_table(path):
98
+ """gene_table.tsv(gem_annotate 产出)-> {gene_id: {locus_tag, gene_name, product}}。"""
99
+ import csv
100
+ mp = {}
101
+ if not path or not os.path.exists(path):
102
+ return mp
103
+ with open(path, encoding="utf-8") as f:
104
+ rd = csv.DictReader(f, delimiter="\t")
105
+ for row in rd:
106
+ gid = (row.get("gene_id") or "").strip()
107
+ if gid:
108
+ mp[gid] = {"locus_tag": (row.get("locus_tag") or "").strip(),
109
+ "gene_name": (row.get("gene_name") or "").strip(),
110
+ "product": (row.get("product") or "").strip()}
111
+ return mp
112
+
113
+
114
+ def _attach_gene_table(result, gene_table):
115
+ """P1-4(2026-08-31 LBA9402 会话实测):坐标型基因 ID 无功能语义,73 个必需基因无法解读。
116
+ 关联 gem_annotate 的 gene_table.tsv -> essential_gene_details 带 locus_tag/product。
117
+ 只增字段不破坏 essential_genes(str 列表)既有消费方(model_card/ledger/sensitivity/benchmark)。"""
118
+ result["gene_table_loaded"] = False
119
+ if not gene_table or not os.path.exists(gene_table):
120
+ return result
121
+ try:
122
+ mp = _load_gene_table(gene_table)
123
+ details = []
124
+ for g in result.get("essential_genes") or []:
125
+ info = mp.get(g) or {}
126
+ details.append({"gene_id": g,
127
+ "locus_tag": info.get("locus_tag") or None,
128
+ "gene_name": info.get("gene_name") or None,
129
+ "product": info.get("product") or None})
130
+ result["gene_table_loaded"] = True
131
+ result["gene_table_path"] = os.path.abspath(gene_table)
132
+ result["essential_gene_details"] = details
133
+ result["gene_table_note"] = ("基因注释表已关联:essential_gene_details 提供坐标ID→locus_tag/product 映射,"
134
+ "用于解读必需基因功能(坐标型 ID 无此表时不可读)")
135
+ except Exception as e:
136
+ sys.stderr.write(f"[scan] gene_table WARN: {type(e).__name__}: {e}\n")
137
+ return result
138
+
139
+
140
+ def essential_scan(model_path, medium=None, gene_subset=None, progress=None, ledger_path=None,
141
+ gene_table=None):
142
+ """全量必需基因扫描。返回 {wt, total_genes, tested, essential, essential_genes,
143
+ predicted_viable, timeout_aborted, fva_seconds, knock_seconds}。"""
144
+ m = silent_read_sbml(model_path)
145
+ resolved, unresolved, preset = setup_model_medium(m, medium)
146
+ print(f"[scan] medium_unresolved = {unresolved}", file=sys.stderr)
147
+
148
+ result = scan_essentiality(m, gene_subset=gene_subset, progress=progress)
149
+ result.update({
150
+ "medium_preset": preset,
151
+ "medium_unresolved": unresolved,
152
+ "note": "必需判定=A 培养基下敲除生长<1e-6;evidence 分级按基因支撑反应是否含 EVIDENCE_math(Q2)",
153
+ # 阶段A-M4 口径声明(只增):wt_growth 为单点 FBA 值
154
+ "units": "mmol/gDW/h",
155
+ "point_value_note": "单点 FBA 值,非解空间硬结论;条件对比请用 gem_fluxscan 区间分离判定",
156
+ })
157
+ # 模型卡 schema v2 回写(产物旁已有 card 才写;无卡不凭空造卡)
158
+ try:
159
+ from model_card import load_card, set_essential_genes
160
+ if load_card(model_path) is not None:
161
+ set_essential_genes(model_path, result, model=m)
162
+ except Exception:
163
+ pass
164
+ # 阶段A遗留修正(退化护栏,只增分支):wt<=EPS 时必需性判定恒真(v=0 使全部候选判"必需",
165
+ # M5 实测曾把 iNX1344_v4 的 1066 条此类退化预测写入真实账本)——跳过 ledger 自动登记。
166
+ # C58 正常路径(wt>EPS)不进此分支,登记行为不变。
167
+ if result["wt_growth"] <= EPS:
168
+ warn = "wt<=EPS:必需性判定恒真,扫描结果无生物学意义,未登记"
169
+ sys.stderr.write(f"[scan] WARN wt_growth={result['wt_growth']} {warn}(ledger)\n")
170
+ result["ledger_registration"] = {"appended": 0,
171
+ "skipped_degraded": len(result["essential_genes"]),
172
+ "degraded": True, "warn": warn}
173
+ return result
174
+ # 阶段A-M3: prediction ledger 自动登记(每必需基因一条;幂等去重;写入失败仅 WARN 不阻塞)
175
+ try:
176
+ import ledger as _ledger
177
+ from model_card import load_card as _load_card
178
+ lineage_v = ((_load_card(model_path) or {}).get("model_lineage") or {}).get("version")
179
+ cond = preset or (f"custom({len(resolved)} EX)" if resolved else "unspecified")
180
+ result["ledger_registration"] = _ledger.register_essentiality(
181
+ model_path, result, model=m, condition=cond, lineage_version=lineage_v,
182
+ path=ledger_path)
183
+ except Exception as e:
184
+ sys.stderr.write(f"[scan] ledger registration WARN: {type(e).__name__}: {e}\n")
185
+ # P1-4:关联基因注释表(坐标ID→locus_tag/product),让必需基因可解读
186
+ if result["wt_growth"] > EPS:
187
+ _attach_gene_table(result, gene_table)
188
+ return result
189
+
190
+
191
+ if __name__ == "__main__":
192
+ import json
193
+ args = json.loads(open(sys.argv[1], encoding="utf-8").read()) if len(sys.argv) > 1 else {}
194
+ print(json.dumps(essential_scan(args.get("model"), args.get("medium"),
195
+ args.get("gene_subset")), ensure_ascii=False, indent=2))
@@ -0,0 +1,302 @@
1
+ # fluxscan.py — M1 通量区间制(阶段 A 可信度内核第一件)
2
+ # 语义(bsp 锁稿,一字不改):每反应输出 fva_min/fva_max/pfba;条件对比消费区间分离判定;
3
+ # overlap = 点值差异是求解器伪影,禁止引用。
4
+ # 计算口径:每 condition 独立 silent_read_sbml 重读模型(勿深拷贝);介质 setup 对齐 validate G3
5
+ # (expand_medium -> 全交换清零 EX_/DM_/SK_/boundary -> resolve_medium 设 bounds);
6
+ # substrates 对齐 validate g4_phenotype(supplement=基准不变+底物 lb=-10;sole=去含碳交换[元素级
7
+ # parse_formula]+底物 lb=-10);FVA processes=1(Windows 无 fork)。
8
+ # 区间分离判定(核心,单测锁定):
9
+ # 分离 = ua + tol < lb -> direction=b_higher
10
+ # ub + tol < la -> direction=a_higher
11
+ # 否则 overlap=true / hard_conclusion=false / direction=null
12
+ # 判定用四舍六入到 6 位后的区间值(与输出/CSV 展示值自洽,tol=1e-6 仍完全覆盖求解器噪声)。
13
+ import os
14
+ import sys
15
+ import csv
16
+ import time
17
+ import json
18
+
19
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
20
+
21
+ from silentio import silent_read_sbml
22
+ from gapfind import expand_medium, resolve_medium, build_ex_index, match_ex
23
+ from validate import parse_formula
24
+
25
+ EX_PREFIX = ("EX_", "DM_", "SK_")
26
+ DEFAULT_FRACTION = 0.9999
27
+ DEFAULT_TOL = 1e-6
28
+ ROUND_N = 6
29
+
30
+ LABEL_OVERLAP = "区间重叠:点值差异=求解器伪影,禁止引用"
31
+ LABEL_A_HIGHER = "区间分离:条件 A 恒高于 B(解空间无关硬结论)"
32
+ LABEL_B_HIGHER = "区间分离:条件 B 恒高于 A(解空间无关硬结论)"
33
+
34
+
35
+ def judge_interval(la, ua, lb, ub, tol=DEFAULT_TOL):
36
+ """区间分离判定(锁定公式)。输入四舍五入后的区间端点,返回 verdict dict。"""
37
+ if ua + tol < lb:
38
+ return {"overlap": False, "hard_conclusion": True, "direction": "b_higher"}
39
+ if ub + tol < la:
40
+ return {"overlap": False, "hard_conclusion": True, "direction": "a_higher"}
41
+ return {"overlap": True, "hard_conclusion": False, "direction": None}
42
+
43
+
44
+ def _r(x):
45
+ """FVA/pFBA 数值规整:None/NaN -> 0.0(可行模型下不应出现,防御性),-0.0 -> 0.0,保留 6 位。"""
46
+ if x is None:
47
+ return 0.0
48
+ f = float(x)
49
+ if f != f: # NaN
50
+ return 0.0
51
+ v = round(f, ROUND_N)
52
+ return 0.0 if v == 0.0 else v
53
+
54
+
55
+ def _setup_condition(model_path, cond, log=lambda s: None):
56
+ """单条件:独立重读模型 + 介质 setup(对齐 validate G3)+ 底物(对齐 g4_phenotype)。
57
+ 返回 (model, info);model 的 bounds 已按该条件设好,供 FBA/FVA/pFBA 使用。"""
58
+ from cobra.flux_analysis import flux_variability_analysis, pfba
59
+ name = cond.get("name")
60
+ carbon_mode = cond.get("carbon_mode", "supplement")
61
+ subs = cond.get("substrates") or []
62
+ if carbon_mode not in ("supplement", "sole"):
63
+ raise ValueError(f"condition {name}: unknown carbon_mode {carbon_mode!r}(supplement|sole)")
64
+
65
+ t0 = time.time()
66
+ m = silent_read_sbml(model_path)
67
+ t_read = time.time() - t0
68
+
69
+ med, preset = expand_medium(cond.get("medium"))
70
+ resolved, unresolved = resolve_medium(m, med) if med else ({}, [])
71
+ for r in m.reactions:
72
+ if r.id.startswith(EX_PREFIX) or r.boundary:
73
+ r.lower_bound = 0.0
74
+ med2 = dict(resolved)
75
+ if carbon_mode == "sole":
76
+ # 去含碳交换(元素级 parse_formula——裸子串会把 Ca/Cl/Co/Cu 误判为碳,历史 bug 勿复刻)
77
+ for rid in list(med2):
78
+ if rid in m.reactions:
79
+ met = list(m.reactions.get_by_id(rid).metabolites)[0]
80
+ if met.formula and "C" in parse_formula(met.formula):
81
+ del med2[rid]
82
+ ex_idx = build_ex_index(m)
83
+ sub_ex, sub_unresolved = {}, []
84
+ for sub in subs:
85
+ exid = match_ex(sub, ex_idx)
86
+ if exid and exid in m.reactions:
87
+ med2[exid] = -10.0
88
+ sub_ex[sub] = exid
89
+ else:
90
+ sub_unresolved.append(sub)
91
+ for rid, lb in med2.items():
92
+ if rid in m.reactions:
93
+ m.reactions.get_by_id(rid).lower_bound = lb
94
+
95
+ t0 = time.time()
96
+ with m:
97
+ wt = m.optimize().objective_value
98
+ if wt is None:
99
+ sys.stderr.write(f"[fluxscan] {name}: FBA infeasible -> growth treated as 0.0\n")
100
+ wt = 0.0
101
+ t_fba = time.time() - t0
102
+
103
+ t0 = time.time()
104
+ fva = flux_variability_analysis(m, fraction_of_optimum=DEFAULT_FRACTION
105
+ if cond.get("_fraction") is None else cond["_fraction"],
106
+ processes=1)
107
+ t_fva = time.time() - t0
108
+
109
+ t0 = time.time()
110
+ sol = pfba(m)
111
+ t_pfba = time.time() - t0
112
+
113
+ info = {
114
+ "name": name,
115
+ "growth": round(float(wt), ROUND_N),
116
+ "medium_preset": preset,
117
+ "resolved_exchanges": sorted(med2),
118
+ "unresolved": unresolved,
119
+ "substrate_exchanges": sub_ex,
120
+ "substrate_unresolved": sub_unresolved,
121
+ "carbon_mode": carbon_mode,
122
+ "_timing": {"read_s": round(t_read, 1), "fba_s": round(t_fba, 2),
123
+ "fva_s": round(t_fva, 1), "pfba_s": round(t_pfba, 1)},
124
+ }
125
+ data = {}
126
+ pf = sol.fluxes
127
+ for rid in fva.index:
128
+ data[rid] = {
129
+ "pfba": _r(pf.get(rid, 0.0)),
130
+ "fva_min": _r(fva.at[rid, "minimum"]),
131
+ "fva_max": _r(fva.at[rid, "maximum"]),
132
+ }
133
+ log(f"[fluxscan] condition {name}: growth={info['growth']} read={t_read:.1f}s "
134
+ f"fba={t_fba:.2f}s fva={t_fva:.1f}s pfba={t_pfba:.1f}s "
135
+ f"resolved={len(med2)} unresolved={unresolved} substrate_unresolved={sub_unresolved}")
136
+ return data, info
137
+
138
+
139
+ def _pair_result(a_name, b_name, data_a, data_b, scope, tol, only_diff):
140
+ """条件对比较:scope 反应逐一区间判定。summary 与 comparisons 同口径(同一 scope)。"""
141
+ comparisons = []
142
+ n_hard = n_a = n_b = 0
143
+ for rid in scope:
144
+ ra, rb = data_a[rid], data_b[rid]
145
+ verdict = judge_interval(ra["fva_min"], ra["fva_max"], rb["fva_min"], rb["fva_max"], tol)
146
+ if verdict["hard_conclusion"]:
147
+ n_hard += 1
148
+ if verdict["direction"] == "a_higher":
149
+ n_a += 1
150
+ else:
151
+ n_b += 1
152
+ if only_diff and not verdict["hard_conclusion"]:
153
+ continue
154
+ comparisons.append({
155
+ "rxn": rid,
156
+ "a": {"pfba": ra["pfba"], "fva_min": ra["fva_min"], "fva_max": ra["fva_max"]},
157
+ "b": {"pfba": rb["pfba"], "fva_min": rb["fva_min"], "fva_max": rb["fva_max"]},
158
+ "overlap": verdict["overlap"],
159
+ "hard_conclusion": verdict["hard_conclusion"],
160
+ "direction": verdict["direction"],
161
+ "label": (LABEL_OVERLAP if verdict["overlap"]
162
+ else (LABEL_A_HIGHER if verdict["direction"] == "a_higher" else LABEL_B_HIGHER)),
163
+ })
164
+ summary = {"total": len(scope), "hard_conclusions": n_hard,
165
+ "overlapping": len(scope) - n_hard, "a_higher": n_a, "b_higher": n_b}
166
+ return {"a": a_name, "b": b_name, "summary": summary, "comparisons": comparisons}
167
+
168
+
169
+ def _export_csv(path, model_reactions, pair_results):
170
+ """全量 CSV:每行 = 条件对 × 反应(双侧区间/点值/判定)。返回 (rows, bytes)。"""
171
+ names = {r.id: r for r in model_reactions}
172
+ rows = 0
173
+ with open(path, "w", newline="", encoding="utf-8-sig") as f:
174
+ w = csv.writer(f)
175
+ w.writerow(["pair", "cond_a", "cond_b", "rxn", "reaction_name", "gpr",
176
+ "a_pfba", "a_fva_min", "a_fva_max",
177
+ "b_pfba", "b_fva_min", "b_fva_max",
178
+ "overlap", "hard_conclusion", "direction", "label"])
179
+ for pr in pair_results:
180
+ key = f"{pr['a']}|vs|{pr['b']}"
181
+ for c in pr["comparisons"]:
182
+ rxn = names.get(c["rxn"])
183
+ w.writerow([key, pr["a"], pr["b"], c["rxn"],
184
+ (rxn.name if rxn is not None else ""),
185
+ (rxn.gpr if rxn is not None else ""),
186
+ c["a"]["pfba"], c["a"]["fva_min"], c["a"]["fva_max"],
187
+ c["b"]["pfba"], c["b"]["fva_min"], c["b"]["fva_max"],
188
+ int(c["overlap"]), int(c["hard_conclusion"]),
189
+ c["direction"] or "", c["label"]])
190
+ rows += 1
191
+ return rows, os.path.getsize(path)
192
+
193
+
194
+ def fluxscan(model_path, conditions, reactions=None, pairs=None,
195
+ fraction_of_optimum=DEFAULT_FRACTION, tolerance=DEFAULT_TOL,
196
+ only_diff=False, export_csv=None, progress=None):
197
+ """M1 通量区间制主入口。conditions.name 必须唯一(重复直接报错)。
198
+ FVA/pFBA 恒全模型计算;reactions 仅收窄 comparisons/summary 口径(两者同口径)。"""
199
+ log = progress or (lambda s: sys.stderr.write(str(s) + "\n"))
200
+ if not conditions or not isinstance(conditions, list):
201
+ raise ValueError("conditions required(非空数组,每项 {name, medium, substrates?, carbon_mode?})")
202
+ names = [c.get("name") for c in conditions]
203
+ if any(not n for n in names):
204
+ raise ValueError("every condition needs a name")
205
+ if len(set(names)) != len(names):
206
+ dup = sorted({n for n in names if names.count(n) > 1})
207
+ raise ValueError(f"conditions.name must be unique, duplicated: {dup}")
208
+
209
+ t0 = time.time()
210
+ m0 = silent_read_sbml(model_path) # 仅用于 reaction 元数据/scope 校验
211
+ all_ids = [r.id for r in m0.reactions]
212
+ log(f"[fluxscan] model {model_path}: {len(all_ids)} reactions; "
213
+ f"fraction_of_optimum={fraction_of_optimum} tolerance={tolerance}")
214
+
215
+ if reactions:
216
+ idset = set(all_ids)
217
+ missing = [x for x in reactions if x not in idset]
218
+ scope = [x for x in reactions if x in idset] # 保持用户给定顺序
219
+ if not scope:
220
+ raise ValueError(f"none of the given reactions exist in model; missing={missing[:10]}")
221
+ else:
222
+ missing = []
223
+ scope = all_ids
224
+
225
+ cond_names = set(names)
226
+ if pairs:
227
+ for p in pairs:
228
+ if len(p) != 2 or p[0] not in cond_names or p[1] not in cond_names:
229
+ raise ValueError(f"invalid pair {p!r}: need two existing condition names")
230
+ else:
231
+ pairs = [[names[i], names[j]] for i in range(len(names)) for j in range(i + 1, len(names))]
232
+
233
+ data_by_cond, info_by_cond = {}, {}
234
+ for cond in conditions:
235
+ cond = dict(cond)
236
+ cond["_fraction"] = fraction_of_optimum
237
+ data, info = _setup_condition(model_path, cond, log=log)
238
+ data_by_cond[info["name"]] = data
239
+ info_by_cond[info["name"]] = info
240
+
241
+ pair_results = [_pair_result(p[0], p[1], data_by_cond[p[0]], data_by_cond[p[1]],
242
+ scope, tolerance, only_diff) for p in pairs]
243
+ total_s = round(time.time() - t0, 1)
244
+
245
+ out = {
246
+ "model": model_path,
247
+ "fraction_of_optimum": fraction_of_optimum,
248
+ "tolerance": tolerance,
249
+ "units": "mmol/gDW/h",
250
+ "reactions_not_found": missing,
251
+ "conditions": [{k: v for k, v in info_by_cond[n].items() if not k.startswith("_")}
252
+ for n in names],
253
+ "pairs": pair_results,
254
+ "timing_seconds": total_s,
255
+ }
256
+ if export_csv:
257
+ rows, size = _export_csv(export_csv, m0.reactions, pair_results)
258
+ out["export_csv"] = export_csv
259
+ out["export_csv_rows"] = rows
260
+ out["export_csv_bytes"] = size
261
+ log(f"[fluxscan] CSV {export_csv}: {rows} rows, {size} bytes")
262
+ log(f"[fluxscan] done in {total_s}s: {len(pair_results)} pairs, scope={len(scope)}")
263
+ return out
264
+
265
+
266
+ if __name__ == "__main__":
267
+ # 双协议(历史坑:stdin 与 argv-file 两派并存,新脚本必须都支持)
268
+ if "--selftest" in sys.argv:
269
+ cases = [
270
+ # (la, ua, lb, ub, tol, direction, hard) —— 任务书 5 组判定样例
271
+ (1.0, 2.0, 5.0, 6.0, 1e-6, "b_higher", True), # ① 分离:b 高
272
+ (5.0, 6.0, 1.0, 2.0, 1e-6, "a_higher", True), # ① 分离:a 高
273
+ (1.0, 3.0, 2.0, 4.0, 1e-6, None, False), # ② 重叠(伪影)
274
+ (1.0, 3.0, 3.0, 5.0, 1e-6, None, False), # ② 接触容差内仍 overlap(边界)
275
+ (-5.0, -2.0, 0.0, 0.0, 1e-6, "b_higher", True), # ③ 零通量/负向
276
+ (-5.0, -2.0, 0.0, 0.0, 0.0, "b_higher", True), # ④ 精确边界(tol=0)
277
+ # ⑤ 容差:锁定公式下 gap=5e-7 < tol=1e-6 -> overlap(任务书该行样例期望"分离",
278
+ # 与锁定公式 ua+tol<lb 矛盾:5e-7 不大于 1e-6。按公式实现,差异上报 bsp 裁决)
279
+ (1.0, 1.0000005, 1.000001, 2.0, 1e-6, None, False),
280
+ # 同数字、tol(1e-7)<gap(5e-7) -> 分离:证明容差机制本身生效
281
+ (1.0, 1.0000005, 1.000001, 2.0, 1e-7, "b_higher", True),
282
+ (0.0, 0.0, 3.0, 5.0, 1e-6, "b_higher", True), # 附加:单侧有通量
283
+ (3.0, 5.0, 0.0, 0.0, 1e-6, "a_higher", True), # 附加:另一侧 [0,0]
284
+ ]
285
+ for la, ua, lb, ub, tol, d, h in cases:
286
+ v = judge_interval(la, ua, lb, ub, tol)
287
+ assert v["direction"] == d and v["hard_conclusion"] == h and v["overlap"] == (d is None), \
288
+ f"judge_interval({la},{ua},{lb},{ub},tol={tol}) -> {v}, expect direction={d} hard={h}"
289
+ print(json.dumps({"ok": True, "result": {"selftest": "pass", "cases": len(cases)}}))
290
+ else:
291
+ args = {}
292
+ if len(sys.argv) > 1:
293
+ with open(sys.argv[1], encoding="utf-8") as f:
294
+ args = json.load(f)
295
+ elif not sys.stdin.isatty():
296
+ args = json.loads(sys.stdin.read())
297
+ a = args.get("args", args)
298
+ print(json.dumps({"ok": True, "result": fluxscan(
299
+ a.get("model"), a.get("conditions"), reactions=a.get("reactions"),
300
+ pairs=a.get("pairs"), fraction_of_optimum=a.get("fraction_of_optimum", DEFAULT_FRACTION),
301
+ tolerance=a.get("tolerance", DEFAULT_TOL), only_diff=a.get("only_diff", False),
302
+ export_csv=a.get("export_csv"))}, ensure_ascii=False))