@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,248 @@
|
|
|
1
|
+
# model_card.py — 模型卡 schema v2 统一写入器(B' 收尾 + Q2 工程质量件)
|
|
2
|
+
# 职责: init_card(build 起卡)/ load / save / append_operation(lineage 版本递增 + changelog)
|
|
3
|
+
# / set_verified_phenotypes(phenotype 结果)/ set_essential_genes(必需基因 + 证据分级)
|
|
4
|
+
# 纪律: 各工具完成后**仅当产物模型旁已有 card** 才向后追加;无卡不动(不凭空造卡)。
|
|
5
|
+
# 兼容: build.py 旧卡(无 schema 字段)读取时即时迁移到 v2(新增字段缺失不报错)。
|
|
6
|
+
# units: growth_rate 一律 mmol/gDW/h(schema v2 规定,勿用 1/h)。
|
|
7
|
+
import os
|
|
8
|
+
import json
|
|
9
|
+
import time
|
|
10
|
+
|
|
11
|
+
CARD_SUFFIX = ".card.json"
|
|
12
|
+
SCHEMA = "v2"
|
|
13
|
+
GROWTH_UNITS = "mmol/gDW/h"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _now():
|
|
17
|
+
return time.strftime("%Y-%m-%dT%H:%M:%S")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def card_path_for(model_path):
|
|
21
|
+
return (model_path[:-4] if model_path.endswith(".xml") else model_path) + CARD_SUFFIX
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def load_card(model_path):
|
|
25
|
+
p = card_path_for(model_path)
|
|
26
|
+
if not os.path.exists(p):
|
|
27
|
+
return None
|
|
28
|
+
with open(p, encoding="utf-8") as f:
|
|
29
|
+
return json.load(f)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def save_card(model_path, card):
|
|
33
|
+
p = card_path_for(model_path)
|
|
34
|
+
with open(p, "w", encoding="utf-8") as f:
|
|
35
|
+
json.dump(card, f, ensure_ascii=False, indent=2)
|
|
36
|
+
return p
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _ensure_v2(card):
|
|
40
|
+
"""legacy 卡(build 直写,无 schema/lineage)即时迁移到 v2。
|
|
41
|
+
v3 卡(阶段A-M2 起,含 robustness 章节)视为已迁移,不降级。"""
|
|
42
|
+
if card.get("schema") in (SCHEMA, "v3") and "model_lineage" in card:
|
|
43
|
+
return card
|
|
44
|
+
card.setdefault("schema", SCHEMA)
|
|
45
|
+
card.setdefault("growth_units", GROWTH_UNITS)
|
|
46
|
+
card["model_lineage"] = {"version": "0.1.0", "operations": card.get("model_lineage", {}).get("operations", [])}
|
|
47
|
+
card.setdefault("changelog", [])
|
|
48
|
+
if not any("adopt" in str(x).lower() for x in card["changelog"]):
|
|
49
|
+
card["changelog"].append(f"{_now()} legacy card adopted into schema v2 (build-time fields preserved)")
|
|
50
|
+
return card
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def init_card(model_path, name=None, engine=None, changelog_note="build", **fields):
|
|
54
|
+
"""build 起卡:已有字段 + schema v2 基座(lineage v0.1.0 起始, changelog=[build])。幂等。"""
|
|
55
|
+
existing = load_card(model_path)
|
|
56
|
+
fresh = existing is None
|
|
57
|
+
card = existing or {"name": name or os.path.basename(model_path), "model": model_path,
|
|
58
|
+
"created": _now()}
|
|
59
|
+
if engine:
|
|
60
|
+
card.setdefault("engine", engine)
|
|
61
|
+
card.update({k: v for k, v in fields.items() if v is not None})
|
|
62
|
+
if fresh:
|
|
63
|
+
card["schema"] = SCHEMA
|
|
64
|
+
card.setdefault("growth_units", GROWTH_UNITS)
|
|
65
|
+
card["model_lineage"] = {"version": "0.1.0", "operations": []}
|
|
66
|
+
card["changelog"] = []
|
|
67
|
+
if changelog_note:
|
|
68
|
+
card["changelog"].append(f"{_now()} {changelog_note}")
|
|
69
|
+
else:
|
|
70
|
+
_ensure_v2(card)
|
|
71
|
+
p = save_card(model_path, card)
|
|
72
|
+
return card, p
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def propagate_card(src_model_path, dst_model_path):
|
|
76
|
+
"""把源模型旁的 card 复制到派生产物旁(dst 已有卡则不动)。
|
|
77
|
+
派生工具(gapfill/l3_fix/phenotype_fix/biomass_apply)产物是新文件——
|
|
78
|
+
先传播再 append_operation,保证派生模型自带完整 lineage。"""
|
|
79
|
+
sp, dp = card_path_for(src_model_path), card_path_for(dst_model_path)
|
|
80
|
+
if os.path.exists(sp) and not os.path.exists(dp):
|
|
81
|
+
import shutil
|
|
82
|
+
shutil.copyfile(sp, dp)
|
|
83
|
+
return dp
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def append_operation(model_path, operation, reactions_added=0, reactions_removed=0, detail=None):
|
|
88
|
+
"""模型旁已有 card 时追加操作记录:lineage 版本递增(patch 号)+ changelog push。
|
|
89
|
+
无 card 返回 None(调用方不应凭空造卡)。"""
|
|
90
|
+
card = load_card(model_path)
|
|
91
|
+
if card is None:
|
|
92
|
+
return None
|
|
93
|
+
_ensure_v2(card)
|
|
94
|
+
lin = card["model_lineage"]
|
|
95
|
+
major, minor, patch = str(lin.get("version", "0.1.0")).split(".")
|
|
96
|
+
lin["version"] = f"{major}.{minor}.{int(patch) + 1}"
|
|
97
|
+
op = {"seq": len(lin["operations"]) + 1, "at": _now(), "operation": operation,
|
|
98
|
+
"reactions_added": reactions_added, "reactions_removed": reactions_removed}
|
|
99
|
+
if detail:
|
|
100
|
+
op["detail"] = detail
|
|
101
|
+
lin["operations"].append(op)
|
|
102
|
+
card["changelog"].append(f"{_now()} {operation} (+{reactions_added}/-{reactions_removed})"
|
|
103
|
+
+ (f" — {json.dumps(detail, ensure_ascii=False)[:160]}" if detail else ""))
|
|
104
|
+
save_card(model_path, card)
|
|
105
|
+
return card
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def set_verified_phenotypes(model_path, phenotype_result, semantics="sole"):
|
|
109
|
+
"""phenotype_fix 结果写入 card.verified_phenotypes。
|
|
110
|
+
phenotype_result: phenotype_fix() 返回(含 before/after/after_results/model)。"""
|
|
111
|
+
card = load_card(model_path)
|
|
112
|
+
if card is None:
|
|
113
|
+
return None
|
|
114
|
+
_ensure_v2(card)
|
|
115
|
+
after = phenotype_result.get("after") or {}
|
|
116
|
+
rows = []
|
|
117
|
+
for r in (phenotype_result.get("after_results") or []):
|
|
118
|
+
rows.append({"substrate": r.get("substrate"), "published": r.get("published"),
|
|
119
|
+
"predicted": r.get("predicted"), "growth": r.get("growth"),
|
|
120
|
+
"exchange": r.get("exchange"), "match": r.get("match"),
|
|
121
|
+
"source": "phenotype_fix/G4-sole"})
|
|
122
|
+
card["verified_phenotypes"] = {
|
|
123
|
+
"semantics": semantics, "units": GROWTH_UNITS,
|
|
124
|
+
"matched": after.get("matched"), "total": after.get("total"),
|
|
125
|
+
"rate": after.get("rate"), "source": "gem_phenotype", "updated_at": _now(),
|
|
126
|
+
"results": rows,
|
|
127
|
+
}
|
|
128
|
+
save_card(model_path, card)
|
|
129
|
+
return card
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def set_essential_genes(model_path, scan_result, model=None):
|
|
133
|
+
"""essential_scan 结果写入 card.essential_genes。
|
|
134
|
+
evidence_level: high_confidence 默认;基因支撑反应含 EVIDENCE_math(l3_fix 数学连接/
|
|
135
|
+
bounds 放宽)→ contains_EVIDENCE_math(必需性判定可能被数学证据反应影响,标注降置信)。"""
|
|
136
|
+
card = load_card(model_path)
|
|
137
|
+
if card is None:
|
|
138
|
+
return None
|
|
139
|
+
_ensure_v2(card)
|
|
140
|
+
math_rxn_ids = set()
|
|
141
|
+
if model is not None:
|
|
142
|
+
for r in model.reactions:
|
|
143
|
+
n = r.notes or {}
|
|
144
|
+
if n.get("evidence") == "EVIDENCE_math" or n.get("bound_relaxed_by"):
|
|
145
|
+
math_rxn_ids.add(r.id)
|
|
146
|
+
genes = []
|
|
147
|
+
for gid in (scan_result.get("essential_genes") or []):
|
|
148
|
+
lvl = "high_confidence"
|
|
149
|
+
try:
|
|
150
|
+
if model is not None and any(r.id in math_rxn_ids for r in model.genes.get_by_id(gid).reactions):
|
|
151
|
+
lvl = "contains_EVIDENCE_math"
|
|
152
|
+
except KeyError:
|
|
153
|
+
pass
|
|
154
|
+
genes.append({"gene_id": gid, "evidence_level": lvl})
|
|
155
|
+
n_math = sum(1 for g in genes if g["evidence_level"] != "high_confidence")
|
|
156
|
+
card["essential_genes"] = {
|
|
157
|
+
"units": GROWTH_UNITS, "medium_preset": scan_result.get("medium_preset"),
|
|
158
|
+
"wt_growth": scan_result.get("wt_growth"), "count": len(genes),
|
|
159
|
+
"n_contains_EVIDENCE_math": n_math, "source": "gem_essentiality", "updated_at": _now(),
|
|
160
|
+
"genes": genes,
|
|
161
|
+
}
|
|
162
|
+
save_card(model_path, card)
|
|
163
|
+
return card
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def set_robustness(model_path, sensitivity_result):
|
|
167
|
+
"""sensitivity 结果写入 card.robustness(阶段A-M2:schema v3 起步,向后兼容 v2 卡只增字段)。
|
|
168
|
+
sensitivity_result: sensitivity() 返回(含 wt_growth_grid/stability/component_sensitivity/gam_carrier)。
|
|
169
|
+
无 card 返回 None(不凭空造卡——纪律同 set_essential_genes)。"""
|
|
170
|
+
card = load_card(model_path)
|
|
171
|
+
if card is None:
|
|
172
|
+
return None
|
|
173
|
+
_ensure_v2(card)
|
|
174
|
+
card["schema"] = "v3" # v3 = v2 + robustness 章节(读取方按 JSON 字段访问,向后兼容)
|
|
175
|
+
grid = sensitivity_result.get("wt_growth_grid") or []
|
|
176
|
+
stab = sensitivity_result.get("stability") or {}
|
|
177
|
+
comp = (sensitivity_result.get("component_sensitivity") or {}).get("top_sensitive") or []
|
|
178
|
+
card["robustness"] = {
|
|
179
|
+
"units": GROWTH_UNITS,
|
|
180
|
+
"combinations": sensitivity_result.get("combinations"),
|
|
181
|
+
"baseline_reproduced": sensitivity_result.get("baseline_reproduced"),
|
|
182
|
+
"wt_growth_grid": [{"biomass": r.get("biomass"), "gam": r.get("gam"),
|
|
183
|
+
"growth": r.get("growth"), "essential_count": r.get("essential_count")}
|
|
184
|
+
for r in grid],
|
|
185
|
+
"stability": {"always_essential_count": len(stab.get("always_essential") or []),
|
|
186
|
+
"always_essential": stab.get("always_essential") or [],
|
|
187
|
+
"conditionally_essential_count": len(stab.get("conditionally_essential") or []),
|
|
188
|
+
"conditionally_essential": stab.get("conditionally_essential") or [],
|
|
189
|
+
"never_essential_count": stab.get("never_essential_count")},
|
|
190
|
+
"component_sensitivity_top": comp,
|
|
191
|
+
"gam_carrier": sensitivity_result.get("gam_carrier"),
|
|
192
|
+
"source": "gem_sensitivity", "updated_at": _now(),
|
|
193
|
+
}
|
|
194
|
+
save_card(model_path, card)
|
|
195
|
+
return card
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
if __name__ == "__main__":
|
|
199
|
+
import sys, tempfile
|
|
200
|
+
# 自检(smoke 用,纯 JSON 层,秒级):init → append ×2 → 版本递增 → phenotype/essential 形状
|
|
201
|
+
if "--selftest" in sys.argv:
|
|
202
|
+
d = tempfile.mkdtemp(prefix="card-selftest-")
|
|
203
|
+
mp = os.path.join(d, "selftest.xml")
|
|
204
|
+
open(mp, "w").close()
|
|
205
|
+
card, p = init_card(mp, name="selftest", engine="test", validations_m9={"g1": "PASS"})
|
|
206
|
+
assert card["model_lineage"]["version"] == "0.1.0" and card["schema"] == "v2"
|
|
207
|
+
c1 = append_operation(mp, "gapfill", reactions_added=2, detail={"note": "sucrose L1"})
|
|
208
|
+
assert c1["model_lineage"]["version"] == "0.1.1" and len(c1["changelog"]) == 2
|
|
209
|
+
c2 = append_operation(mp, "l3_fix", reactions_added=3)
|
|
210
|
+
assert c2["model_lineage"]["version"] == "0.1.2" and c2["model_lineage"]["operations"][0]["reactions_added"] == 2
|
|
211
|
+
set_verified_phenotypes(mp, {"after": {"matched": 13, "total": 19, "rate": 0.684},
|
|
212
|
+
"after_results": [{"substrate": "Arabinose", "published": 1,
|
|
213
|
+
"predicted": 1, "match": True}]})
|
|
214
|
+
set_essential_genes(mp, {"essential_genes": ["NC_003062_2_1", "NC_003062_2_2"],
|
|
215
|
+
"wt_growth": 0.52, "medium_preset": "AB"}, model=None)
|
|
216
|
+
back = load_card(mp)
|
|
217
|
+
assert back["verified_phenotypes"]["matched"] == 13
|
|
218
|
+
assert back["essential_genes"]["count"] == 2
|
|
219
|
+
assert back["essential_genes"]["genes"][0]["evidence_level"] == "high_confidence"
|
|
220
|
+
# 阶段A-M2:robustness 章节(v2→v3 只增字段)+ 无 card 不造卡
|
|
221
|
+
assert set_robustness(mp + ".nonexistent", {"wt_growth_grid": []}) is None
|
|
222
|
+
c4 = set_robustness(mp, {"combinations": 22, "baseline_reproduced": True,
|
|
223
|
+
"wt_growth_grid": [{"biomass": 1.0, "gam": 40.0, "growth": 0.519981,
|
|
224
|
+
"essential_count": 155}],
|
|
225
|
+
"stability": {"always_essential": ["g1"], "conditionally_essential": [],
|
|
226
|
+
"never_essential_count": 0},
|
|
227
|
+
"component_sensitivity": {"top_sensitive": [{"component": "cpd00023_c0",
|
|
228
|
+
"delta_pct": -12.5}]},
|
|
229
|
+
"gam_carrier": {"type": "inside_biomass", "gam_orig": 40.0}})
|
|
230
|
+
assert c4["schema"] == "v3" and c4["robustness"]["combinations"] == 22
|
|
231
|
+
assert c4["essential_genes"]["count"] == 2 and c4["model_lineage"]["version"] == "0.1.2"
|
|
232
|
+
# legacy 迁移
|
|
233
|
+
legacy = {"name": "legacy", "engine": "carveme", "growth_g3_m9": 0.782}
|
|
234
|
+
with open(card_path_for(mp), "w", encoding="utf-8") as f:
|
|
235
|
+
json.dump(legacy, f)
|
|
236
|
+
c3 = append_operation(mp, "gapfill", reactions_added=1)
|
|
237
|
+
assert c3["schema"] == "v2" and c3["model_lineage"]["version"] == "0.1.1" and c3["growth_g3_m9"] == 0.782
|
|
238
|
+
print('{"ok": true, "result": {"selftest": "pass", "version_after": "%s"}}' % back["model_lineage"]["version"])
|
|
239
|
+
else:
|
|
240
|
+
import json as _j
|
|
241
|
+
args = _j.loads(open(sys.argv[1], encoding="utf-8").read()) if len(sys.argv) > 1 else {}
|
|
242
|
+
model = args.get("model")
|
|
243
|
+
op = args.get("action", "show")
|
|
244
|
+
if op == "init":
|
|
245
|
+
card, p = init_card(model, name=args.get("name"), engine=args.get("engine"), **(args.get("fields") or {}))
|
|
246
|
+
print(_j.dumps({"ok": True, "result": {"card": p, "version": card["model_lineage"]["version"]}}))
|
|
247
|
+
else:
|
|
248
|
+
print(_j.dumps({"ok": True, "result": load_card(model)}))
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# phenotype_fix.py — 路线 A3:表型回填迭代
|
|
2
|
+
# 流程: G4 表型对照(supplement)-> 失配底物逐个 gapfind 分级
|
|
3
|
+
# -> L1/L2 规则补洞(累积到新模型)-> L3 列候选清单 -> 重跑 G4 对比匹配率
|
|
4
|
+
# 输出: {before_rate, after_rate, fixed, unresolved_L3, model}
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
import cobra
|
|
8
|
+
|
|
9
|
+
from silentio import silent_read_sbml, silent_write_sbml
|
|
10
|
+
from validate import validate_model
|
|
11
|
+
from gapfind import find_gaps, expand_medium, resolve_medium
|
|
12
|
+
from gapfill import apply_fixes
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _note(*a):
|
|
16
|
+
"""进度走 stderr(stdout 是 JSON 协议通道,绝不能污染)。"""
|
|
17
|
+
print(*a, file=sys.stderr)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# 排除纯 N 源底物(sole 无碳时不长属正常,非缺口)
|
|
21
|
+
N_SOURCES = {"nh3", "nitrate", "nitrite", "ammonia", "ammonium", "nitrogen", "urea"}
|
|
22
|
+
def _is_n_source(name):
|
|
23
|
+
return (name or "").strip().lower() in N_SOURCES
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def phenotype_fix(model_path, phenotype_table=None, medium=None, max_add=20, out=None, ledger_path=None):
|
|
27
|
+
if not phenotype_table or not os.path.exists(phenotype_table):
|
|
28
|
+
return {"error": "phenotype_table 必需(TSV: substrate<TAB>published 0/1)"}
|
|
29
|
+
med, preset = expand_medium(medium)
|
|
30
|
+
med_resolved, unresolved = resolve_medium(silent_read_sbml(model_path), med) if med else ({}, [])
|
|
31
|
+
|
|
32
|
+
def g4_on(path):
|
|
33
|
+
rep = validate_model(path, medium=med, phenotype_table=phenotype_table,
|
|
34
|
+
carbon_mode="sole") # 缺口检测用唯一碳源语义(防 AB 葡萄糖掩盖)
|
|
35
|
+
g4 = rep.get("g4") or {}
|
|
36
|
+
if g4.get("status") == "SKIP":
|
|
37
|
+
return None, g4
|
|
38
|
+
return (g4.get("matched", 0), g4.get("total", 0)), g4
|
|
39
|
+
|
|
40
|
+
# before(sole 语义)
|
|
41
|
+
(bm, bt), g4b = g4_on(model_path)
|
|
42
|
+
before_rate = bm / bt if bt else None
|
|
43
|
+
targets = [x for x in (g4b.get("results") or [])
|
|
44
|
+
if x.get("published") == 1 and x.get("predicted") == 0
|
|
45
|
+
and not _is_n_source(x.get("substrate"))]
|
|
46
|
+
print(f"[before] 匹配 {bm}/{bt}({before_rate:.1%});需修复底物 {len(targets)} 个", file=sys.stderr)
|
|
47
|
+
|
|
48
|
+
# 逐个修复(L1/L2 规则;L3 列清单)
|
|
49
|
+
cur = model_path
|
|
50
|
+
fixed, l3_list = [], []
|
|
51
|
+
if targets:
|
|
52
|
+
for t in targets:
|
|
53
|
+
gf = apply_fixes(cur, medium=med, substrates=[t["substrate"]],
|
|
54
|
+
max_add=max_add, out=(out or (model_path[:-4] + "_pf.xml")))
|
|
55
|
+
new_fixed = gf.get("applied") or []
|
|
56
|
+
if new_fixed and gf.get("out"):
|
|
57
|
+
cur = gf["out"] # 累积:后续修复基于最新模型
|
|
58
|
+
fixed.extend(new_fixed)
|
|
59
|
+
# L3 候选(gapfind 单独跑该底物)
|
|
60
|
+
gaps = find_gaps(cur, medium=med, substrates=[t["substrate"]])
|
|
61
|
+
for x in gaps.get("L3", []):
|
|
62
|
+
l3_list.append({"substrate": t["substrate"], **x})
|
|
63
|
+
print(f"修复 {len(fixed)} 项;L3 候选 {len(l3_list)} 个", file=sys.stderr)
|
|
64
|
+
|
|
65
|
+
# after
|
|
66
|
+
(am, at), g4a = g4_on(cur)
|
|
67
|
+
after_rate = am / at if at else None
|
|
68
|
+
print(f"[after] 匹配 {am}/{at}({after_rate:.1%})", file=sys.stderr)
|
|
69
|
+
|
|
70
|
+
result = {
|
|
71
|
+
"before": {"matched": bm, "total": bt, "rate": before_rate},
|
|
72
|
+
"after": {"matched": am, "total": at, "rate": after_rate},
|
|
73
|
+
"improved": (after_rate or 0) > (before_rate or 0),
|
|
74
|
+
"fixed": fixed,
|
|
75
|
+
"l3_remaining": l3_list,
|
|
76
|
+
"after_results": g4a.get("results") or [],
|
|
77
|
+
"model": cur,
|
|
78
|
+
"medium_preset": preset,
|
|
79
|
+
"medium_unresolved": unresolved,
|
|
80
|
+
}
|
|
81
|
+
# 模型卡 schema v2 回写(源模型旁有 card 才传播+追加;无卡不凭空造卡)
|
|
82
|
+
card_version = None
|
|
83
|
+
try:
|
|
84
|
+
from model_card import append_operation, load_card, propagate_card, set_verified_phenotypes
|
|
85
|
+
propagate_card(model_path, cur)
|
|
86
|
+
if load_card(cur) is not None:
|
|
87
|
+
set_verified_phenotypes(cur, result, semantics="sole")
|
|
88
|
+
card = append_operation(cur, "phenotype_fix", reactions_added=len(fixed),
|
|
89
|
+
detail={"before": result["before"], "after": result["after"],
|
|
90
|
+
"l3_remaining": len(l3_list)})
|
|
91
|
+
card_version = (card or {}).get("model_lineage", {}).get("version")
|
|
92
|
+
except Exception:
|
|
93
|
+
pass
|
|
94
|
+
result["card_version"] = card_version
|
|
95
|
+
# 阶段A-M3: prediction ledger 自动登记(每底物一条 G4 结果;幂等去重;失败仅 WARN)
|
|
96
|
+
try:
|
|
97
|
+
import ledger as _ledger
|
|
98
|
+
from model_card import load_card as _load_card
|
|
99
|
+
lineage_v = ((_load_card(cur) or {}).get("model_lineage") or {}).get("version")
|
|
100
|
+
cond = (preset or "custom") + "/sole"
|
|
101
|
+
result["ledger_registration"] = _ledger.register_phenotype(
|
|
102
|
+
cur, result.get("after_results") or [], condition=cond, lineage_version=lineage_v,
|
|
103
|
+
model=silent_read_sbml(cur), path=ledger_path)
|
|
104
|
+
except Exception as e:
|
|
105
|
+
print(f"[phenotype_fix] ledger registration WARN: {type(e).__name__}: {e}", file=sys.stderr)
|
|
106
|
+
return result
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
if __name__ == "__main__":
|
|
110
|
+
import json
|
|
111
|
+
import sys
|
|
112
|
+
args = json.loads(open(sys.argv[1], encoding="utf-8").read()) if len(sys.argv) > 1 else {}
|
|
113
|
+
print(json.dumps(phenotype_fix(args.get("model"), args.get("phenotype_table"),
|
|
114
|
+
args.get("medium"), args.get("max_add", 20),
|
|
115
|
+
args.get("out")), ensure_ascii=False, indent=2))
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# roundtrip_check.py — SBML 往返保真自检(Q2 工程质量件 A)
|
|
2
|
+
# cobra 读 → write_sbml_model → 读回:断言反应/代谢物/基因数一致 + GPR 字符串精确一致
|
|
3
|
+
# (GLM 经典暗坑:序列化静默丢 GPR——fbc v2 写入路径回归护栏;≥5 个复合 GPR 反应样本必查)
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
import json
|
|
7
|
+
import tempfile
|
|
8
|
+
|
|
9
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def roundtrip_check(model_path, min_complex_gpr=5):
|
|
13
|
+
from silentio import silent_read_sbml, silent_write_sbml
|
|
14
|
+
m = silent_read_sbml(model_path)
|
|
15
|
+
gpr = {r.id: r.gene_reaction_rule for r in m.reactions if r.gene_reaction_rule}
|
|
16
|
+
# 复合 GPR:and/or 混合或带括号(最容易在序列化中丢结构)
|
|
17
|
+
complex_ids = [k for k, v in gpr.items() if (" and " in v and " or " in v) or "(" in v]
|
|
18
|
+
out = os.path.join(tempfile.mkdtemp(prefix="gem-rt-"), os.path.basename(model_path))
|
|
19
|
+
silent_write_sbml(m, out)
|
|
20
|
+
m2 = silent_read_sbml(out)
|
|
21
|
+
gpr2 = {r.id: r.gene_reaction_rule for r in m2.reactions if r.gene_reaction_rule}
|
|
22
|
+
counts = {"reactions": [len(m.reactions), len(m2.reactions)],
|
|
23
|
+
"metabolites": [len(m.metabolites), len(m2.metabolites)],
|
|
24
|
+
"genes": [len(m.genes), len(m2.genes)]}
|
|
25
|
+
counts_ok = all(v[0] == v[1] for v in counts.values())
|
|
26
|
+
diffs = {k: {"before": gpr[k], "after": gpr2.get(k)} for k in gpr if gpr[k] != gpr2.get(k)}
|
|
27
|
+
sampled = sorted(complex_ids)[:min_complex_gpr]
|
|
28
|
+
sample_diffs = [k for k in sampled if gpr[k] != gpr2.get(k)]
|
|
29
|
+
return {
|
|
30
|
+
"ok": counts_ok and not diffs and len(sampled) >= min_complex_gpr and not sample_diffs,
|
|
31
|
+
"model": model_path,
|
|
32
|
+
"counts": counts,
|
|
33
|
+
"gpr_total": len(gpr), "gpr_complex_available": len(complex_ids),
|
|
34
|
+
"gpr_compared": len(sampled), "gpr_diffs": len(diffs), "gpr_sample_diffs": sample_diffs,
|
|
35
|
+
"detail_first_diffs": dict(list(diffs.items())[:3]),
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
if __name__ == "__main__":
|
|
40
|
+
# 协议与 gem_ops 一致:JSON 走 stdin(smoke runPy 直传);兼容 argv 文件
|
|
41
|
+
raw = sys.stdin.read() if not sys.stdin.isatty() else ""
|
|
42
|
+
args = json.loads(raw) if raw.strip() else (
|
|
43
|
+
json.loads(open(sys.argv[1], encoding="utf-8").read()) if len(sys.argv) > 1 else {})
|
|
44
|
+
print(json.dumps(roundtrip_check(args.get("model"), args.get("min_complex_gpr", 5)),
|
|
45
|
+
ensure_ascii=False, indent=2))
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# secretion.py — 阶段C-C1 可分泌代谢物谱(菌种通用)
|
|
2
|
+
# 候选 = 介质层两级策略导出的交换反应(build_ex_index:EX_ 型与 boundary 型模型都适用)。
|
|
3
|
+
# 可分泌判定 = production envelope 扫描:固定生长分数 {0.25,0.5,0.75,0.9,0.99,1.0} 下产物交换最大化
|
|
4
|
+
# (强制 biomass 通量 >= fraction*wt,最大化交换反应的分泌方向通量);
|
|
5
|
+
# 任一分数 >0 且产物交换 > 1e-6 → 可分泌。
|
|
6
|
+
# 边界声明(方案文件要求,内置于输出):未考虑毒性/渗透压/调控,纯拓扑/线性规划结果。
|
|
7
|
+
# 退化护栏(阶段 A/B 教训):被测模型 wt<=EPS(介质下不生长,如 AB 预设对非根瘤菌科物种——
|
|
8
|
+
# 阶段B-B3 molybdate 教训)→ 不扫描不登记账本,输出 degenerate:true + 介质适配提示。
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
import csv
|
|
12
|
+
import time
|
|
13
|
+
import json
|
|
14
|
+
|
|
15
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
16
|
+
|
|
17
|
+
from silentio import silent_read_sbml
|
|
18
|
+
from gapfind import build_ex_index, ex_index_is_boundary
|
|
19
|
+
from essential_scan import setup_model_medium
|
|
20
|
+
from sensitivity import find_biomass_gam
|
|
21
|
+
|
|
22
|
+
EPS = 1e-6
|
|
23
|
+
GROWTH_FRACTIONS = [0.25, 0.5, 0.75, 0.9, 0.99, 1.0]
|
|
24
|
+
BOUNDARY_NOTE = "未考虑毒性/渗透压/调控,纯拓扑/线性规划结果"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _envelope_for(m, bio, rxn, wt, fractions):
|
|
28
|
+
"""单候选 envelope:各生长分数下分泌方向最大通量。返回 (env_rows, max_prod, growth_at_max)。"""
|
|
29
|
+
met = list(rxn.metabolites)[0]
|
|
30
|
+
c = rxn.metabolites[met]
|
|
31
|
+
env, best, best_f = [], 0.0, None
|
|
32
|
+
for f in fractions:
|
|
33
|
+
with m:
|
|
34
|
+
bio.lower_bound = f * wt
|
|
35
|
+
m.objective = rxn
|
|
36
|
+
m.objective_direction = "max" if c < 0 else "min"
|
|
37
|
+
v = m.slim_optimize()
|
|
38
|
+
prod = round(abs(v), 6) if (v is not None and v == v) else 0.0
|
|
39
|
+
env.append({"fraction": f, "prod": prod})
|
|
40
|
+
if prod > best:
|
|
41
|
+
best, best_f = prod, f
|
|
42
|
+
return env, best, (best_f if best_f is not None else 0.0)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def secretion(model_path, medium=None, fractions=None, export_csv=None,
|
|
46
|
+
ledger_refs=True, ledger_path=None, progress=None,
|
|
47
|
+
mode="full", summary_top=20):
|
|
48
|
+
"""可分泌代谢物谱。
|
|
49
|
+
P0-1(2026-08-31 LBA9402 会话实测):full 模式 185KB 输出被引擎省略截断,agent 绕道 bio_python
|
|
50
|
+
又不被防火墙背书 -> 死锁。mode=summary(op 层默认)只返回 top N 可分泌物(不含 envelope);
|
|
51
|
+
完整数据请 export_csv 落盘 CSV,返回里给 full_data_file 显式路径。全量语义保持 mode=full。"""
|
|
52
|
+
log = progress or (lambda s: sys.stderr.write(str(s) + "\n"))
|
|
53
|
+
medium = medium or {"medium_name": "AB"}
|
|
54
|
+
fractions = sorted(fractions or GROWTH_FRACTIONS)
|
|
55
|
+
t0 = time.time()
|
|
56
|
+
m = silent_read_sbml(model_path)
|
|
57
|
+
idx = build_ex_index(m)
|
|
58
|
+
boundary_style = ex_index_is_boundary(idx)
|
|
59
|
+
resolved, unresolved, preset = setup_model_medium(m, medium)
|
|
60
|
+
gi = find_biomass_gam(m)
|
|
61
|
+
bio = m.reactions.get_by_id(gi["biomass_rxn"])
|
|
62
|
+
with m:
|
|
63
|
+
wt = m.optimize().objective_value
|
|
64
|
+
wt = round(float(wt), 6) if wt is not None else 0.0
|
|
65
|
+
log(f"[secretion] {model_path} medium={medium} wt={wt} candidates={len(set(idx.values()))} "
|
|
66
|
+
f"boundary_style={boundary_style}")
|
|
67
|
+
|
|
68
|
+
out = {
|
|
69
|
+
"model": model_path, "medium": medium, "medium_preset": preset,
|
|
70
|
+
"units": "mmol/gDW/h", "boundary_note": BOUNDARY_NOTE,
|
|
71
|
+
"growth_fractions": fractions, "boundary_style": boundary_style,
|
|
72
|
+
"candidates": len(set(idx.values())), "unresolved_medium": unresolved,
|
|
73
|
+
"wt_growth": wt,
|
|
74
|
+
"degenerate": wt <= EPS,
|
|
75
|
+
}
|
|
76
|
+
if out["degenerate"]:
|
|
77
|
+
out["degenerate_note"] = (f"wt_growth={wt}<=EPS:被测模型在指定介质下不生长,production envelope 无意义,"
|
|
78
|
+
"未扫描、未登记账本。提示:内置介质预设为根瘤菌科(C58)调校,非根瘤菌模型需先做介质适配"
|
|
79
|
+
"(阶段B-B3 教训:iML1515 严格 AB 缺 molybdate)。")
|
|
80
|
+
try:
|
|
81
|
+
from benchmark import medium_adaptation_hints
|
|
82
|
+
out["medium_adaptation_hints"] = medium_adaptation_hints(model_path, medium)
|
|
83
|
+
except Exception as e:
|
|
84
|
+
sys.stderr.write(f"[secretion] hints WARN: {type(e).__name__}: {e}\n")
|
|
85
|
+
log(f"[secretion] DEGENERATE wt={wt} <= EPS:不扫描不登记")
|
|
86
|
+
return out
|
|
87
|
+
|
|
88
|
+
rows = []
|
|
89
|
+
for rid in sorted(set(idx.values())):
|
|
90
|
+
rxn = m.reactions.get_by_id(rid)
|
|
91
|
+
mets = list(rxn.metabolites)
|
|
92
|
+
if len(mets) != 1:
|
|
93
|
+
continue # 交换候选恒单代谢物;多代谢物防御性跳过
|
|
94
|
+
env, best, best_f = _envelope_for(m, bio, rxn, wt, fractions)
|
|
95
|
+
met = mets[0]
|
|
96
|
+
rows.append({"rxn": rid, "met_id": met.id, "name": met.name or "",
|
|
97
|
+
"max_prod": best, "growth_at_max": round(best_f * wt, 6),
|
|
98
|
+
"feasible": best > EPS, "envelope": env})
|
|
99
|
+
feasible = [r for r in rows if r["feasible"]]
|
|
100
|
+
log(f"[secretion] scan done: {len(rows)} candidates, {len(feasible)} feasible "
|
|
101
|
+
f"({round(time.time() - t0, 1)}s)")
|
|
102
|
+
|
|
103
|
+
# 账本登记(每个可分泌代谢物一条 type=secretion;幂等 sha256 去重)
|
|
104
|
+
reg = None
|
|
105
|
+
if ledger_refs:
|
|
106
|
+
try:
|
|
107
|
+
import ledger as _ledger
|
|
108
|
+
from model_card import load_card as _load_card
|
|
109
|
+
lineage_v = ((_load_card(model_path) or {}).get("model_lineage") or {}).get("version")
|
|
110
|
+
cond = preset or (f"custom({len(resolved)} EX)" if resolved else "unspecified")
|
|
111
|
+
reg = _ledger.register_secretion(model_path, feasible, condition=cond,
|
|
112
|
+
lineage_version=lineage_v, path=ledger_path)
|
|
113
|
+
out["ledger_registration"] = reg
|
|
114
|
+
log(f"[secretion] ledger: appended={reg['appended']} skipped={reg['skipped_duplicates']}")
|
|
115
|
+
except Exception as e:
|
|
116
|
+
sys.stderr.write(f"[secretion] ledger registration WARN: {type(e).__name__}: {e}\n")
|
|
117
|
+
|
|
118
|
+
out.update({
|
|
119
|
+
"mode": mode,
|
|
120
|
+
"secretable_count": len(feasible),
|
|
121
|
+
"timing_seconds": round(time.time() - t0, 1),
|
|
122
|
+
})
|
|
123
|
+
if mode == "summary":
|
|
124
|
+
# P0-1:只返回 top N(瘦身:不含 envelope),防大输出省略截断
|
|
125
|
+
top = sorted(feasible, key=lambda r: r["max_prod"], reverse=True)[:summary_top]
|
|
126
|
+
slim = [{k: r[k] for k in ("rxn", "met_id", "name", "max_prod", "growth_at_max", "feasible")}
|
|
127
|
+
for r in top]
|
|
128
|
+
out["summary_top"] = len(slim)
|
|
129
|
+
out["summary_note"] = (f"summary 模式仅返回 top{len(slim)}(按 max_prod)可分泌条目,未内联 envelope 曲线;"
|
|
130
|
+
f"完整 {len(feasible)} 条及 envelope 数据见 full_data_file(CSV)或用 mode=full。"
|
|
131
|
+
f"本工具默认 summary 是为避免大输出被平台省略截断——不要在 summary 返回里找截断区数字。")
|
|
132
|
+
out["results"] = slim
|
|
133
|
+
else:
|
|
134
|
+
out["results"] = rows
|
|
135
|
+
if reg is not None:
|
|
136
|
+
out["ledger_registration"] = reg
|
|
137
|
+
if export_csv:
|
|
138
|
+
n = _export_csv(export_csv, rows, out)
|
|
139
|
+
out["export_csv"] = export_csv
|
|
140
|
+
out["export_csv_rows"] = n
|
|
141
|
+
out["export_csv_bytes"] = os.path.getsize(export_csv)
|
|
142
|
+
# 数据位置显式置顶提示字段(P0-1:agent 不再需要从省略输出里找数字)
|
|
143
|
+
out["full_data_file"] = export_csv
|
|
144
|
+
out["full_data_file_note"] = "完整可分泌清单+envelope 曲线的权威文件(以上输出无论 summary/full 均指此为准)"
|
|
145
|
+
log(f"[secretion] CSV {export_csv}: {n} rows")
|
|
146
|
+
elif mode == "summary":
|
|
147
|
+
out["full_data_file"] = None
|
|
148
|
+
out["full_data_file_note"] = ("本次未导出 CSV;需要完整可分泌清单请以 export_csv 参数指定落盘路径后重跑"
|
|
149
|
+
"(幂等,账本跳过重复)")
|
|
150
|
+
return out
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _export_csv(path, rows, out):
|
|
154
|
+
n = 0
|
|
155
|
+
with open(path, "w", newline="", encoding="utf-8-sig") as f:
|
|
156
|
+
w = csv.writer(f)
|
|
157
|
+
w.writerow(["# boundary_note", out["boundary_note"]])
|
|
158
|
+
w.writerow(["rxn", "met_id", "name", "feasible", "max_prod", "growth_at_max",
|
|
159
|
+
"fraction", "prod"])
|
|
160
|
+
for r in rows:
|
|
161
|
+
for e in r["envelope"]:
|
|
162
|
+
w.writerow([r["rxn"], r["met_id"], r["name"], int(r["feasible"]),
|
|
163
|
+
r["max_prod"], r["growth_at_max"], e["fraction"], e["prod"]])
|
|
164
|
+
n += 1
|
|
165
|
+
return n
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
if __name__ == "__main__":
|
|
169
|
+
args = {}
|
|
170
|
+
if len(sys.argv) > 1:
|
|
171
|
+
with open(sys.argv[1], encoding="utf-8") as f:
|
|
172
|
+
args = json.load(f)
|
|
173
|
+
elif not sys.stdin.isatty():
|
|
174
|
+
args = json.loads(sys.stdin.read())
|
|
175
|
+
a = args.get("args", args)
|
|
176
|
+
print(json.dumps({"ok": True, "result": secretion(
|
|
177
|
+
a.get("model"), medium=a.get("medium"), fractions=a.get("fractions"),
|
|
178
|
+
export_csv=a.get("export_csv"), ledger_refs=a.get("ledger_refs", True),
|
|
179
|
+
ledger_path=a.get("ledger_path"))}, ensure_ascii=False))
|