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