@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,484 @@
1
+ # sensitivity.py — 阶段A-M2 结构性灵敏度(把"模型不确定"量化)
2
+ # 网格(全量,不抽样): biomass 组分系数 ×{0.75, 1.0, 1.25} × GAM {1,5,10,20,30,40,50} = 21 扫点
3
+ # + 1 基准组合(biomass×1.0 且 GAM=原始值,不扰动)= 22 组合;每组合 wt growth + 必需性重扫
4
+ # (复用 essential_scan.setup_model_medium / scan_essentiality——M2 顺带工程改进)。
5
+ # 正交化: C58 的 GAM 载体在 biomass 方程 bio1 内部(ATP 水解 stub 五元组 ATP/H2O/ADP/Pi/H+,
6
+ # GAM=40.0 mmol ATP/gDW 以 ADP 系数为净水电解量)——biomass 组分缩放排除该 stub 与 Biomass
7
+ # 产物(目标汇连接组分),GAM 网格只动 stub(等比缩放 X/GAM_ORIG)。
8
+ # 锚点: 基准组合与 essential_scan 完全同参数 -> 必须精确复现 155;且 155 全部在
9
+ # always_essential ∪ conditionally_essential(基准在网格内故断言必成立)。
10
+ # 生长/通量数值口径: 单点 FBA objective_value(mmol/gDW/h);区间制对比请用 gem_fluxscan。
11
+ import os
12
+ import sys
13
+ import csv
14
+ import time
15
+ import json
16
+
17
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
18
+
19
+ from silentio import silent_read_sbml
20
+ from validate import parse_formula
21
+ from essential_scan import setup_model_medium, scan_essentiality
22
+
23
+ BIOMASS_SCALES = [0.75, 1.0, 1.25]
24
+ GAM_GRID = [1, 5, 10, 20, 30, 40, 50]
25
+ EPS = 1e-6
26
+ LP_TIMEOUT_S = 30 # 单次 LP 求解上限(秒)。正常 <0.05s;仅防个别扰动 LP 的 GLPK 病态停摆(实测会卡死)
27
+
28
+
29
+ def _set_lp_timeout(m, seconds=LP_TIMEOUT_S):
30
+ """GLPK 病态停摆护栏:optlang configuration.timeout。设置失败不影响主流程。"""
31
+ try:
32
+ m.solver.configuration.timeout = seconds
33
+ except Exception:
34
+ pass
35
+
36
+
37
+ # ---------------------------------------------------------------------------
38
+ # GAM 载体定位(跨命名空间:公式级识别,不依赖 MetaCyc/BiGG/ModelSEED id 体系)
39
+ # ---------------------------------------------------------------------------
40
+ def _classify_energy_met(met):
41
+ """按元素组成识别能量 stub 角色(ATP/ADP/Pi/H2O/H+)。返回角色名或 None。"""
42
+ f = parse_formula(met.formula or "")
43
+ c, p, n, o, h = f.get("C", 0), f.get("P", 0), f.get("N", 0), f.get("O", 0), f.get("H", 0)
44
+ if c == 10 and p == 3 and n == 5:
45
+ return "atp"
46
+ if c == 10 and p == 2 and n == 5:
47
+ return "adp"
48
+ if c == 0 and p == 1 and n == 0:
49
+ return "pi"
50
+ if c == 0 and p == 0 and n == 0 and o == 1 and h == 2:
51
+ return "h2o"
52
+ if c == 0 and p == 0 and n == 0 and o == 0 and h == 1:
53
+ return "h"
54
+ return None
55
+
56
+
57
+ def find_biomass_gam(m):
58
+ """定位 biomass 反应与 GAM 载体。返回
59
+ {biomass_rxn, objective_rxn, biomass_product, carrier_type, gam_mets{role:met_id},
60
+ gam_orig, stub_coeffs{role:coeff}, n_components, scaled_components}。
61
+ carrier_type: inside_biomass(stub 在 biomass 方程内)| independent_reaction | not_found。"""
62
+ obj_rxns = [r for r in m.reactions if r.objective_coefficient != 0]
63
+ if not obj_rxns:
64
+ raise ValueError("no objective reaction found")
65
+ cands, product_id = [], None
66
+ for r in obj_rxns:
67
+ if len(r.metabolites) >= 10:
68
+ cands.append(r)
69
+ else:
70
+ # gapseq 惯例:objective 是 Biomass 代谢物的汇(EX/DM),真 biomass 是其生产者
71
+ for met in r.metabolites:
72
+ product_id = met.id
73
+ for r2 in met.reactions:
74
+ if r2.id != r.id and len(r2.metabolites) >= 10:
75
+ cands.append(r2)
76
+ if not cands:
77
+ raise ValueError("biomass reaction not found(objective 及其汇生产者均 <10 组分)")
78
+ bio = max(cands, key=lambda r: len(r.metabolites))
79
+
80
+ stub, stub_coeffs = {}, {}
81
+ unclassified_energy_scale = []
82
+ for met, coeff in bio.metabolites.items():
83
+ role = _classify_energy_met(met)
84
+ if role and abs(coeff) > 1.0 and role not in stub:
85
+ stub[role] = met.id
86
+ stub_coeffs[role] = coeff
87
+ # 阶段A-M5 适配(iNX1344_v4 探索结论):H2O 角色的代谢物可能公式缺失(如 M00001_c
88
+ # formula=None),且其量级与 GAM 净水电解量(ADP 系数)一致——量级回退补判。
89
+ if {"atp", "adp", "pi", "h"} <= set(stub) and "h2o" not in stub:
90
+ gam_scale = abs(stub_coeffs["adp"])
91
+ for met, coeff in bio.metabolites.items():
92
+ if met.id in set(stub.values()) or abs(coeff) <= 1.0:
93
+ continue
94
+ if _classify_energy_met(met) is None \
95
+ and abs(abs(coeff) - gam_scale) / gam_scale < 0.05:
96
+ stub["h2o"] = met.id
97
+ stub_coeffs["h2o"] = coeff
98
+ break
99
+ carrier, gam_orig = "not_found", None
100
+ if all(k in stub for k in ("atp", "adp", "pi", "h2o", "h")):
101
+ carrier = "inside_biomass"
102
+ gam_orig = abs(stub_coeffs["adp"])
103
+ else:
104
+ # 独立载体候选:纯 ATPM 型维持反应——反应内全部代谢物都是能量 stub 角色
105
+ # (M5 探索教训:含额外底物的 ATP 水解反应如谷氨酰胺合成酶会误命中,须排除)
106
+ for r in m.reactions:
107
+ roles = [_classify_energy_met(met)
108
+ for met in r.metabolites]
109
+ if r.metabolites and all(roles) and set(roles) <= {"atp", "adp", "pi", "h2o", "h"} \
110
+ and {"atp", "adp", "pi"} <= set(roles):
111
+ carrier = "independent_reaction"
112
+ gam_mets = {met.id for met in r.metabolites}
113
+ gam_orig = abs(r.lower_bound) if r.lower_bound > 0 else None
114
+ return {"biomass_rxn": bio.id, "objective_rxn": obj_rxns[0].id,
115
+ "biomass_product": product_id, "carrier_type": carrier,
116
+ "gam_reaction": r.id, "gam_mets": sorted(gam_mets),
117
+ "gam_orig": gam_orig, "stub_coeffs": {},
118
+ "n_components": len(bio.metabolites),
119
+ "scaled_components": len(bio.metabolites)}
120
+ skip = set(stub.values()) | ({product_id} if product_id else set())
121
+ return {"biomass_rxn": bio.id, "objective_rxn": obj_rxns[0].id,
122
+ "biomass_product": product_id, "carrier_type": carrier,
123
+ "gam_mets": stub, "gam_orig": gam_orig, "stub_coeffs": stub_coeffs,
124
+ "n_components": len(bio.metabolites),
125
+ "scaled_components": len(bio.metabolites) - len(skip)}
126
+
127
+
128
+ def _apply_biomass_scale(m, bio_id, f, gam_info):
129
+ """biomass 组分缩放 ×f(排除 GAM stub 与 Biomass 产物——正交化)。
130
+ cobra add_metabolites 是增量语义:绝对设定必须用 delta = 目标-现值(Q2 教训),回读校验。
131
+ 注意:原始系数必须在 add 之前快照(add 后 bio.metabolites 已是新值)。"""
132
+ bio = m.reactions.get_by_id(bio_id)
133
+ skip = set((gam_info.get("gam_mets") or {}).values()) | \
134
+ ({gam_info["biomass_product"]} if gam_info.get("biomass_product") else set())
135
+ originals = {met: old for met, old in bio.metabolites.items() if met.id not in skip}
136
+ bio.add_metabolites({met: old * f - old for met, old in originals.items()})
137
+ errs = []
138
+ for met, old in originals.items():
139
+ want = old * f
140
+ got = bio.metabolites.get(met)
141
+ if got is None or abs(got - want) > 1e-9:
142
+ errs.append({"met": met.id, "want": want, "got": got})
143
+ if errs:
144
+ raise RuntimeError(f"biomass scale verify failed: {errs[:3]}")
145
+
146
+
147
+ def _apply_gam(m, bio_id, gam_value, gam_info):
148
+ """GAM 设为 gam_value:5 元 stub 等比缩放 gam_value/GAM_ORIG(保持 gapseq stub 内部比例,
149
+ 含 ATP -40.165476 vs ADP +40.0 的记账不对称)。回读校验。"""
150
+ bio = m.reactions.get_by_id(bio_id)
151
+ f = gam_value / gam_info["gam_orig"]
152
+ deltas = {}
153
+ for role, mid in gam_info["gam_mets"].items():
154
+ met = m.metabolites.get_by_id(mid)
155
+ old = bio.metabolites.get(met)
156
+ if old is None:
157
+ raise RuntimeError(f"GAM stub met {mid} not in {bio_id}")
158
+ deltas[met] = old * f - old
159
+ bio.add_metabolites(deltas)
160
+ errs = []
161
+ for role, mid in gam_info["gam_mets"].items():
162
+ met = m.metabolites.get_by_id(mid)
163
+ got = bio.metabolites.get(met)
164
+ want = gam_info["stub_coeffs"][role] * f
165
+ if got is None or abs(got - want) > 1e-6:
166
+ errs.append({"met": mid, "want": want, "got": got})
167
+ if errs:
168
+ raise RuntimeError(f"GAM set verify failed: {errs[:3]}")
169
+
170
+
171
+ # ---------------------------------------------------------------------------
172
+ # 稳定性三分类(纯函数,selftest 锁定)
173
+ # ---------------------------------------------------------------------------
174
+ def classify_stability(combos, total_genes):
175
+ """combos: [{"label", "essential_genes": [...]}, ...]。返回
176
+ {"always_essential", "conditionally_essential":[{gene,pattern}], "never_essential_count"}。"""
177
+ from collections import Counter
178
+ sets = [set(c["essential_genes"]) for c in combos]
179
+ labels = [c["label"] for c in combos]
180
+ always = set.intersection(*sets) if sets else set()
181
+ union = set.union(*sets) if sets else set()
182
+ conditional = union - always
183
+ out_always = sorted(always)
184
+ out_cond = []
185
+ for gid in sorted(conditional):
186
+ on = [labels[i] for i, s in enumerate(sets) if gid in s]
187
+ off = [labels[i] for i, s in enumerate(sets) if gid not in s]
188
+ pattern = (f"essential in {len(on)}/{len(sets)}"
189
+ + (";非必需组合: " + ", ".join(off[:6]) + ("…" if len(off) > 6 else "") if off else
190
+ ";必需组合: " + ", ".join(on[:6]) + ("…" if len(on) > 6 else "")))
191
+ out_cond.append({"gene": gid, "pattern": pattern})
192
+ return {"always_essential": out_always,
193
+ "conditionally_essential": out_cond,
194
+ "never_essential_count": max(0, total_genes - len(union))}
195
+
196
+
197
+ def _combo_scan(model_path, medium, bio_id, gam_info, scale, gam_value, label, log):
198
+ """单组合:fresh read -> (可选)扰动 -> 介质 setup -> wt + 必需性重扫。"""
199
+ m = silent_read_sbml(model_path)
200
+ _set_lp_timeout(m)
201
+ if scale is not None and scale != 1.0:
202
+ _apply_biomass_scale(m, bio_id, scale, gam_info)
203
+ if gam_value is not None and gam_info.get("gam_orig") and abs(gam_value - gam_info["gam_orig"]) > 1e-12:
204
+ _apply_gam(m, bio_id, gam_value, gam_info)
205
+ resolved, unresolved, preset = setup_model_medium(m, medium)
206
+ res = scan_essentiality(m)
207
+ row = {"label": label, "biomass": scale, "gam": gam_value,
208
+ "growth": res["wt_growth"], "essential_count": res["essential_count"],
209
+ "essential_genes": res["essential_genes"], "tested_genes": res["tested_genes"]}
210
+ log(f"[sens] {label}: growth={res['wt_growth']} essential={res['essential_count']} "
211
+ f"(fva {res['fva_seconds']}s knock {res['knock_seconds']}s)")
212
+ return row, m
213
+
214
+
215
+ def sensitivity(model_path, medium=None, biomass_scales=None, gam_grid=None,
216
+ run_component_sensitivity=True, run_drift=True, top_n=10,
217
+ export_csv=None, progress=None, baseline_check=None):
218
+ """M2 主入口。baseline_check: 可选外部基线必需集(来自 essential_scan 直跑)——
219
+ 基准组合与其做集合相等断言(任务书锚点)。返回完整结果 dict。"""
220
+ log = progress or (lambda s: sys.stderr.write(str(s) + "\n"))
221
+ biomass_scales = biomass_scales or BIOMASS_SCALES
222
+ gam_grid = gam_grid or GAM_GRID
223
+ t_start = time.time()
224
+
225
+ # 0) 结构探索(GAM 载体)
226
+ m0 = silent_read_sbml(model_path)
227
+ gam_info = find_biomass_gam(m0)
228
+ log(f"[sens] biomass={gam_info['biomass_rxn']} carrier={gam_info['carrier_type']} "
229
+ f"GAM_ORIG={gam_info['gam_orig']} components={gam_info['n_components']} "
230
+ f"scaled={gam_info['scaled_components']}")
231
+ if gam_info["carrier_type"] != "inside_biomass":
232
+ log(f"[sens] WARN: GAM 载体非 biomass 内部 stub({gam_info['carrier_type']}),GAM 轴行为见报告")
233
+
234
+ grid = []
235
+ # 1) 基准组合(不扰动)——必须与 essential_scan 同参数同结果
236
+ t0 = time.time()
237
+ base_row, m_base = _combo_scan(model_path, medium, gam_info["biomass_rxn"], gam_info,
238
+ None, None, f"biomass=1.0,gam=orig({gam_info['gam_orig']})", log)
239
+ base_row["baseline"] = True
240
+ grid.append(base_row)
241
+ baseline_set = set(base_row["essential_genes"])
242
+ degenerate = base_row["growth"] <= EPS # 阶段A-M5 发现:wt=0 时必需性判定退化(候选全判"必需")
243
+ if degenerate:
244
+ log("[sens] WARN: 基准组合 wt_growth<=0(介质不可解析/模型不生长)——必需性判定退化,"
245
+ "结果仅证明工具在该模型上跑通,essential 集无生物学意义")
246
+ if baseline_check is not None:
247
+ base_row["baseline_matches_essential_scan"] = (baseline_set == set(baseline_check))
248
+ log(f"[sens] 基准组合 vs essential_scan: {len(baseline_set)} vs {len(set(baseline_check))} "
249
+ f"-> {'EXACT MATCH' if base_row['baseline_matches_essential_scan'] else 'MISMATCH(实现 bug,须修复)'}")
250
+
251
+ # 2) 21 扫点(biomass 轴 × GAM 轴;gam=orig 的扫点与基准互为对照)
252
+ for f in biomass_scales:
253
+ for gv in gam_grid:
254
+ label = f"biomass={f},gam={gv}"
255
+ row, _ = _combo_scan(model_path, medium, gam_info["biomass_rxn"], gam_info,
256
+ f, float(gv), label, log)
257
+ grid.append(row)
258
+ log(f"[sens] grid done: {len(grid)} combos in {round(time.time()-t0,1)}s")
259
+
260
+ # 3) 稳定性三分类 + 锚点断言(155 ⊆ always ∪ conditional)
261
+ combos = [{"label": r["label"], "essential_genes": r["essential_genes"]} for r in grid]
262
+ total_genes = len(m0.genes)
263
+ stability = classify_stability(combos, total_genes)
264
+ covered = set(stability["always_essential"]) | {c["gene"] for c in stability["conditionally_essential"]}
265
+ baseline_assert_ok = baseline_set <= covered
266
+ if not baseline_assert_ok:
267
+ log(f"[sens] ASSERT FAIL: baseline essential not in always∪conditional: "
268
+ f"{sorted(baseline_set - covered)[:10]}")
269
+ log(f"[sens] stability: always={len(stability['always_essential'])} "
270
+ f"conditional={len(stability['conditionally_essential'])} "
271
+ f"never={stability['never_essential_count']} baseline_assert_ok={baseline_assert_ok}")
272
+
273
+ # 4) 二级:单组分 ±25% 灵敏度(只测 wt growth;with m 上下文自动回滚 delta)
274
+ # 用 fresh 模型(m_base 经历 FVA+818 敲除上下文,实测复用其求解器状态会病态变慢)
275
+ comp_rows, top_sensitive = [], []
276
+ g0 = base_row["growth"]
277
+ if run_component_sensitivity:
278
+ t0 = time.time()
279
+ m_comp = silent_read_sbml(model_path)
280
+ _set_lp_timeout(m_comp)
281
+ setup_model_medium(m_comp, medium)
282
+ bio = m_comp.reactions.get_by_id(gam_info["biomass_rxn"])
283
+ skip = set(gam_info["gam_mets"].values()) | \
284
+ ({gam_info["biomass_product"]} if gam_info.get("biomass_product") else set())
285
+ comps = [(met, c) for met, c in bio.metabolites.items() if met.id not in skip]
286
+ for idx, (met, old) in enumerate(comps):
287
+ gds = {}
288
+ t_flag = False
289
+ for f in (0.75, 1.25):
290
+ with m_comp:
291
+ bio.add_metabolites({met: old * f - old})
292
+ try:
293
+ v = m_comp.optimize().objective_value
294
+ except Exception as e: # LP 超时/求解器异常:如实标记,不阻塞
295
+ sys.stderr.write(f"[sens] LP fail {met.id} f={f}: {type(e).__name__}: {e}\n")
296
+ v = None
297
+ t_flag = True
298
+ got = bio.metabolites.get(met)
299
+ if got is None or abs(got - old) > 1e-9:
300
+ raise RuntimeError(f"with-context did not revert {met.id}: got {got}, want {old}")
301
+ gds[f] = round(float(v), 6) if v is not None else None
302
+ d75 = round((gds[0.75] - g0) / g0 * 100, 4) if (g0 > EPS and gds[0.75] is not None) else None
303
+ d125 = round((gds[1.25] - g0) / g0 * 100, 4) if (g0 > EPS and gds[1.25] is not None) else None
304
+ comp_rows.append({"component": met.id, "met_name": met.name, "coeff": round(old, 6),
305
+ "growth_x0.75": gds[0.75], "delta_pct_x0.75": d75,
306
+ "growth_x1.25": gds[1.25], "delta_pct_x1.25": d125,
307
+ "lp_timeout": t_flag,
308
+ "max_abs_delta_pct": max(abs(d75 or 0.0), abs(d125 or 0.0))})
309
+ sys.stderr.write(f"[sens] comp {idx+1}/{len(comps)} {met.id} "
310
+ f"d75={d75} d125={d125}{' TIMEOUT' if t_flag else ''}\n")
311
+ comp_rows.sort(key=lambda r: -r["max_abs_delta_pct"])
312
+ top_sensitive = [{"component": r["component"], "met_name": r["met_name"],
313
+ "delta_pct_x0.75": r["delta_pct_x0.75"],
314
+ "delta_pct_x1.25": r["delta_pct_x1.25"],
315
+ "max_abs_delta_pct": r["max_abs_delta_pct"]}
316
+ for r in comp_rows[:top_n]]
317
+ log(f"[sens] component sensitivity: {len(comp_rows)} comps × 2 in {round(time.time()-t0,1)}s; "
318
+ f"top={[(t['component'], t['max_abs_delta_pct']) for t in top_sensitive[:3]]}")
319
+
320
+ # 5) top 敏感组分必需性重扫(漂移)——LP 超时的组分跳过(其扰动 LP 有 GLPK 停摆前科)
321
+ drift, skipped_drift = [], []
322
+ if run_drift and run_component_sensitivity and top_sensitive:
323
+ t0 = time.time()
324
+ for t in top_sensitive:
325
+ trow = next((r for r in comp_rows if r["component"] == t["component"]), {})
326
+ if trow.get("lp_timeout"):
327
+ skipped_drift.append(t["component"])
328
+ continue
329
+ met0 = m_comp.metabolites.get_by_id(t["component"])
330
+ old0 = bio.metabolites.get(met0)
331
+ if old0 is None:
332
+ continue
333
+ for f in (0.75, 1.25):
334
+ label = f"{t['component']}×{f}"
335
+ # 前置生长探针:扰动后不生长(刚性组分对,如 ACP↔apo-ACP 任一 ±25% 都使
336
+ # biomass 方程不可满足)时必需性判定无意义(wt=0 会把全部候选判"必需")——跳过重扫
337
+ m_probe = silent_read_sbml(model_path)
338
+ _set_lp_timeout(m_probe)
339
+ b_probe = m_probe.reactions.get_by_id(gam_info["biomass_rxn"])
340
+ met_probe = m_probe.metabolites.get_by_id(t["component"])
341
+ old_probe = b_probe.metabolites.get(met_probe)
342
+ b_probe.add_metabolites({met_probe: old_probe * f - old_probe})
343
+ setup_model_medium(m_probe, medium)
344
+ with m_probe:
345
+ g_probe = m_probe.optimize().objective_value
346
+ if g_probe is None or g_probe <= EPS:
347
+ drift.append({"component": t["component"], "met_name": t["met_name"],
348
+ "scale": f, "label": label, "wt_growth": 0.0,
349
+ "essentiality_undefined": True,
350
+ "note": "扰动后模型不生长(组分刚性/二元脆性),必需性漂移判定无意义,跳过重扫"})
351
+ log(f"[sens] drift {label}: growth<=0 -> essentiality undefined, skipped")
352
+ continue
353
+ m = silent_read_sbml(model_path)
354
+ _set_lp_timeout(m)
355
+ b = m.reactions.get_by_id(gam_info["biomass_rxn"])
356
+ met2 = m.metabolites.get_by_id(t["component"])
357
+ old2 = b.metabolites.get(met2)
358
+ b.add_metabolites({met2: old2 * f - old2})
359
+ setup_model_medium(m, medium)
360
+ try:
361
+ res = scan_essentiality(m)
362
+ except Exception as e:
363
+ drift.append({"component": t["component"], "met_name": t["met_name"],
364
+ "scale": f, "label": label, "error": f"{type(e).__name__}: {e}"})
365
+ log(f"[sens] drift {label}: FAILED {type(e).__name__}: {e}")
366
+ continue
367
+ var_set = set(res["essential_genes"])
368
+ drift.append({"component": t["component"], "met_name": t["met_name"],
369
+ "scale": f, "label": label, "wt_growth": res["wt_growth"],
370
+ "essential_count": res["essential_count"],
371
+ "lost_essential": sorted(baseline_set - var_set),
372
+ "gained_essential": sorted(var_set - baseline_set)})
373
+ log(f"[sens] drift {label}: essential={res['essential_count']} "
374
+ f"lost={len(baseline_set - var_set)} gained={len(var_set - baseline_set)}")
375
+ log(f"[sens] drift done in {round(time.time()-t0,1)}s; skipped_lp_timeout={skipped_drift}")
376
+
377
+ # 6) 模型卡鲁棒性章节(无 card 不造卡)
378
+ card_written = False
379
+ out = {
380
+ "model": model_path,
381
+ "medium": medium,
382
+ "combinations": len(grid),
383
+ "gam_carrier": {k: v for k, v in gam_info.items()},
384
+ "wt_growth_grid": grid,
385
+ "baseline_essential_count": len(baseline_set),
386
+ "baseline_reproduced": bool(base_row.get("baseline_matches_essential_scan",
387
+ baseline_set is not None)) if baseline_check is not None else None,
388
+ "baseline_assert_always_or_conditional": baseline_assert_ok,
389
+ "baseline_growth_degenerate": bool(degenerate),
390
+ "stability": stability,
391
+ "component_sensitivity": {"top_sensitive": top_sensitive, "rows": comp_rows},
392
+ "component_essentiality_drift": drift,
393
+ "card_robustness_written": card_written,
394
+ "units": "mmol/gDW/h",
395
+ "timing_seconds": round(time.time() - t_start, 1),
396
+ }
397
+ try:
398
+ from model_card import set_robustness
399
+ payload = dict(out)
400
+ payload.pop("component_sensitivity", None)
401
+ payload["component_sensitivity"] = {"top_sensitive": top_sensitive}
402
+ r = set_robustness(model_path, payload)
403
+ card_written = r is not None
404
+ out["card_robustness_written"] = card_written
405
+ if not card_written:
406
+ log("[sens] 模型旁无 card -> robustness 章节未写(无卡不造卡纪律)")
407
+ except Exception as e:
408
+ log(f"[sens] card robustness write failed: {e}")
409
+
410
+ if export_csv:
411
+ try:
412
+ rows_n = _export_csv(export_csv, grid, comp_rows, drift, stability)
413
+ out["export_csv"] = export_csv
414
+ out["export_csv_rows"] = rows_n
415
+ out["export_csv_bytes"] = os.path.getsize(export_csv)
416
+ log(f"[sens] CSV {export_csv}: {rows_n} rows")
417
+ except Exception as e: # CSV 失败不拖垮已完成的全量计算结果
418
+ out["export_csv_error"] = f"{type(e).__name__}: {e}"
419
+ log(f"[sens] CSV export FAILED: {out['export_csv_error']}")
420
+ return out
421
+
422
+
423
+ def _export_csv(path, grid, comp_rows, drift, stability):
424
+ rows = 0
425
+ with open(path, "w", newline="", encoding="utf-8-sig") as f:
426
+ w = csv.writer(f)
427
+ w.writerow(["section", "key1", "key2", "value_num", "value_str"])
428
+ for r in grid:
429
+ w.writerow(["grid", r["label"], "growth", r["growth"], r.get("baseline", False)])
430
+ w.writerow(["grid", r["label"], "essential_count", r["essential_count"],
431
+ ";".join(r["essential_genes"])[:32000]])
432
+ rows += 2
433
+ for r in comp_rows:
434
+ w.writerow(["component", r["component"], "x0.75", r["growth_x0.75"], r["delta_pct_x0.75"]])
435
+ w.writerow(["component", r["component"], "x1.25", r["growth_x1.25"], r["delta_pct_x1.25"]])
436
+ rows += 2
437
+ for r in drift:
438
+ if r.get("error") or r.get("essentiality_undefined"):
439
+ w.writerow(["drift", r["label"], "skipped", r.get("essential_count", ""),
440
+ str(r.get("error") or r.get("note"))[:300]])
441
+ rows += 1
442
+ continue
443
+ w.writerow(["drift", r["label"], "essential_count", r["essential_count"],
444
+ "lost=" + ";".join(r["lost_essential"])[:8000] +
445
+ "|gained=" + ";".join(r["gained_essential"])[:8000]])
446
+ rows += 1
447
+ for g in stability["always_essential"]:
448
+ w.writerow(["stability", g, "always", 1, ""]); rows += 1
449
+ for c in stability["conditionally_essential"]:
450
+ w.writerow(["stability", c["gene"], "conditional", 0, c["pattern"]]); rows += 1
451
+ return rows
452
+
453
+
454
+ if __name__ == "__main__":
455
+ if "--selftest" in sys.argv:
456
+ # 纯函数级:稳定性三分类(无模型依赖)
457
+ combos = [
458
+ {"label": "A", "essential_genes": ["g1", "g2", "g3"]},
459
+ {"label": "B", "essential_genes": ["g1", "g2", "g4"]},
460
+ {"label": "C", "essential_genes": ["g1", "g2"]},
461
+ ]
462
+ st = classify_stability(combos, total_genes=6)
463
+ assert st["always_essential"] == ["g1", "g2"], st
464
+ assert {c["gene"] for c in st["conditionally_essential"]} == {"g3", "g4"}, st
465
+ assert st["never_essential_count"] == 2, st # g5, g6
466
+ assert "essential in 1/3" in st["conditionally_essential"][0]["pattern"]
467
+ # 单组合边界:全 always
468
+ st2 = classify_stability([{"label": "A", "essential_genes": ["g1"]}], total_genes=2)
469
+ assert st2["always_essential"] == ["g1"] and st2["never_essential_count"] == 1
470
+ print(json.dumps({"ok": True, "result": {"selftest": "pass"}}))
471
+ else:
472
+ args = {}
473
+ if len(sys.argv) > 1:
474
+ with open(sys.argv[1], encoding="utf-8") as f:
475
+ args = json.load(f)
476
+ elif not sys.stdin.isatty():
477
+ args = json.loads(sys.stdin.read())
478
+ a = args.get("args", args)
479
+ print(json.dumps({"ok": True, "result": sensitivity(
480
+ a.get("model"), medium=a.get("medium"),
481
+ biomass_scales=a.get("biomass_scales"), gam_grid=a.get("gam_grid"),
482
+ run_component_sensitivity=a.get("run_component_sensitivity", True),
483
+ run_drift=a.get("run_drift", True), top_n=a.get("top_n", 10),
484
+ export_csv=a.get("export_csv"))}, ensure_ascii=False))
@@ -0,0 +1,28 @@
1
+ # silentio.py — 静默加载 SBML(协议保洁)
2
+ # cobra.io.read_sbml_model 在加载某些模型(如 CarveMe fbc2)时会把
3
+ # "Adding exchange reaction ..." / "Ignoring reaction ... already exists" 打印到 stdout,
4
+ # 污染 JSON 协议输出。所有需要读模型的入口统一走 silent_read_sbml。
5
+ import contextlib
6
+ import io
7
+ import sys
8
+ import cobra
9
+
10
+ # Windows 下 stdout/stderr 默认按 locale(GBK)编解码——统一强制 UTF-8,任何模块 import
11
+ # 本文件即生效(gem_ops.py 已有同款 reconfigure,双保险幂等)。
12
+ for _s in ("stdout", "stderr"):
13
+ try:
14
+ getattr(sys, _s).reconfigure(encoding="utf-8")
15
+ except Exception:
16
+ pass
17
+
18
+
19
+ def silent_read_sbml(path):
20
+ """读 SBML,同时截断 cobra/底层库对 stdout/stderr 的打印。"""
21
+ with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
22
+ return cobra.io.read_sbml_model(path)
23
+
24
+
25
+ def silent_write_sbml(m, path):
26
+ """写 SBML(镜像需求:写盘也可能有库打印)。"""
27
+ with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
28
+ cobra.io.write_sbml_model(m, path)
@@ -0,0 +1,151 @@
1
+ # targets.py — 阶段C-C4 靶点清单规范导出(下游接口面;菌种通用)
2
+ # 汇总来源:账本(essentiality/synthetic_lethal/secretion 预测;essential 默认读账本不重扫)。
3
+ # 输出 schema(锁定,每行 11 字段):target_id/type/genes/met_ids/condition/rationale/
4
+ # evidence_tier/status/growth_or_maxprod/source/exported_at。
5
+ # 定位:供下游引物/编辑工具直接输入的规范格式——引物/质粒设计本身不做(方案文件明确)。
6
+ # 与账本计数闭合:exported 条数 = 账本对应 type 计数。
7
+ import os
8
+ import re
9
+ import sys
10
+ import csv
11
+ import time
12
+ import json
13
+
14
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
15
+
16
+ SCHEMA_FIELDS = ["target_id", "type", "genes", "met_ids", "condition", "rationale",
17
+ "evidence_tier", "status", "growth_or_maxprod", "source", "exported_at"]
18
+ TYPE_ORDER = ["essentiality", "synthetic_lethal", "secretion"]
19
+ TYPE_ALIASES = {"essential": "essentiality", "synthetic_lethal": "synthetic_lethal",
20
+ "secretion": "secretion"}
21
+
22
+
23
+ def _parse_gene(content):
24
+ return (content or "").split(" 在 ")[0].strip()
25
+
26
+
27
+ def _parse_secretion(content):
28
+ """'代谢物 cpd00036_e0(Succinate-e0) 在 AB 下模型预测可分泌(max_prod=6.9273 mmol/gDW/h)'"""
29
+ met = None
30
+ m = re.search(r"代谢物 (\S+?)[((]", content or "")
31
+ if m:
32
+ met = m.group(1)
33
+ mp = None
34
+ m2 = re.search(r"max_prod=([0-9.eE+-]+)", content or "")
35
+ if m2:
36
+ mp = float(m2.group(1))
37
+ return met, mp
38
+
39
+
40
+ def _parse_pair(content):
41
+ """'geneA 与 geneB 在 AB 下合成致死'"""
42
+ m = re.match(r"(\S+) 与 (\S+) 在 ", content or "")
43
+ return [m.group(1), m.group(2)] if m else []
44
+
45
+
46
+ def targets(model_path=None, types=None, condition=None, ledger_path=None,
47
+ export_format="csv", export_path=None, progress=None):
48
+ log = progress or (lambda s: sys.stderr.write(str(s) + "\n"))
49
+ import ledger as _ledger
50
+ if not ledger_path:
51
+ ledger_path = _ledger.model_ledger_path(model_path) # 2026-08-31:默认=该模型自己的账本
52
+ rows, corrupt = _ledger.load_rows(ledger_path)
53
+ types = [TYPE_ALIASES.get(t, t) for t in (types or ["essentiality", "synthetic_lethal", "secretion"])]
54
+ types = [t for t in types if t in TYPE_ORDER]
55
+
56
+ # 模型过滤:精确匹配优先,退化到 basename 匹配(防路径大小写/斜杠差异)
57
+ def model_match(r):
58
+ if model_path is None:
59
+ return True
60
+ rm = r.get("model") or ""
61
+ if rm == model_path:
62
+ return True
63
+ return os.path.basename(rm) == os.path.basename(model_path)
64
+
65
+ selected = [r for r in rows
66
+ if r.get("type") in types and model_match(r)
67
+ and (condition is None or str(r.get("condition") or "").lower()
68
+ == str(condition).lower())]
69
+ selected.sort(key=lambda r: (TYPE_ORDER.index(r["type"]), r.get("prediction_id") or ""))
70
+
71
+ now = time.strftime("%Y-%m-%dT%H:%M:%S")
72
+ out_rows = []
73
+ for i, r in enumerate(selected, 1):
74
+ rtype = r["type"]
75
+ content = r.get("content") or ""
76
+ genes, met_ids, gmp = [], [], None
77
+ if rtype == "essentiality":
78
+ genes = [g for g in [_parse_gene(content)] if g]
79
+ elif rtype == "synthetic_lethal":
80
+ genes = _parse_pair(content)
81
+ elif rtype == "secretion":
82
+ met, mp = _parse_secretion(content)
83
+ met_ids = [met] if met else []
84
+ gmp = mp
85
+ out_rows.append({
86
+ "target_id": f"T{i:04d}", "type": rtype, "genes": genes, "met_ids": met_ids,
87
+ "condition": r.get("condition"),
88
+ "rationale": content,
89
+ "evidence_tier": r.get("evidence_tier"),
90
+ "status": r.get("status") or "unverified",
91
+ "growth_or_maxprod": gmp,
92
+ "source": f"ledger:{r.get('prediction_id')}",
93
+ "exported_at": now,
94
+ })
95
+
96
+ # 计数闭合:exported per type == 账本对应 type 计数(同过滤口径)
97
+ ledger_counts = {}
98
+ for r in rows:
99
+ if model_match(r) and (condition is None or str(r.get("condition") or "").lower()
100
+ == str(condition).lower()):
101
+ ledger_counts[r.get("type")] = ledger_counts.get(r.get("type"), 0) + 1
102
+ exported_counts = {}
103
+ for r in out_rows:
104
+ exported_counts[r["type"]] = exported_counts.get(r["type"], 0) + 1
105
+ closure = {t: {"exported": exported_counts.get(t, 0),
106
+ "ledger": ledger_counts.get(t, 0),
107
+ "closed": exported_counts.get(t, 0) == ledger_counts.get(t, 0)}
108
+ for t in types}
109
+
110
+ # 落盘(缺省 ~/.dsh/dsh-bio-gem/exports/targets_<ts>.<ext>)
111
+ if export_path is None:
112
+ d = os.path.join(os.path.expanduser("~"), ".dsh", "dsh-bio-gem", "exports")
113
+ os.makedirs(d, exist_ok=True)
114
+ export_path = os.path.join(d, f"targets_{time.strftime('%Y%m%d_%H%M%S')}.{export_format}")
115
+ if export_format == "json":
116
+ with open(export_path, "w", encoding="utf-8") as f:
117
+ json.dump(out_rows, f, ensure_ascii=False, indent=1)
118
+ else:
119
+ with open(export_path, "w", newline="", encoding="utf-8-sig") as f:
120
+ w = csv.DictWriter(f, fieldnames=SCHEMA_FIELDS, extrasaction="ignore")
121
+ w.writeheader()
122
+ for r in out_rows:
123
+ w.writerow({**r, "genes": ";".join(r["genes"]),
124
+ "met_ids": ";".join(r["met_ids"])})
125
+ log(f"[targets] exported {len(out_rows)} rows -> {export_path}")
126
+
127
+ return {
128
+ "model": model_path, "types": types, "condition": condition,
129
+ "schema_fields": SCHEMA_FIELDS,
130
+ "exported_count": len(out_rows),
131
+ "count_closure": closure,
132
+ "corrupt_rows": len(corrupt),
133
+ "export_format": export_format,
134
+ "export_path": export_path,
135
+ "rows": out_rows,
136
+ "note": "供下游引物/编辑工具直接输入的规范导出;引物/质粒设计本身不在本插件范围(方案文件明确)",
137
+ }
138
+
139
+
140
+ if __name__ == "__main__":
141
+ args = {}
142
+ if len(sys.argv) > 1:
143
+ with open(sys.argv[1], encoding="utf-8") as f:
144
+ args = json.load(f)
145
+ elif not sys.stdin.isatty():
146
+ args = json.loads(sys.stdin.read())
147
+ a = args.get("args", args)
148
+ print(json.dumps({"ok": True, "result": targets(
149
+ model_path=a.get("model"), types=a.get("types"), condition=a.get("condition"),
150
+ ledger_path=a.get("ledger_path"), export_format=a.get("export_format", "csv"),
151
+ export_path=a.get("export_path"))}, ensure_ascii=False))