@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,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))
|