@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,329 @@
1
+ # biomass_tools.py — biomass 精修工具链(Q2 任务一)
2
+ # inspect(只读): biomass 组分表 + 类别分布(氨基酸/核酸/脂质/辅因子/金属/其他)+ 原子总量
3
+ # + 可选参考对照(内置 iML1515 biomass;iNX1344_v4 按代谢物名同义尽力翻译,翻不了明示 unmapped)
4
+ # apply(显式): biomass_profile 覆盖表(op=set|add|remove)→ 副本替换 biomass → 强制 G1-G6 重验
5
+ # + 三联对照(生长/表型/必需基因 delta)→ model_lineage 追加(有 card 时)
6
+ # 原则: 默认不应用任何 profile;生长变差 WARN 不阻塞;C58 CarveMe AB 0.624 锚点保护(delta 如实报告)。
7
+ # units: growth 一律 mmol/gDW/h。
8
+ import os
9
+ import re
10
+ import sys
11
+ import time
12
+ import json
13
+
14
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
15
+
16
+ import cobra
17
+
18
+ EX_PREFIX = ("EX_", "DM_", "SK_")
19
+ DEFAULT_UNIVERSAL = r"D:\Program\hermes\temp\gem_universal\iML1515.xml"
20
+ DEFAULT_INX = r"F:\A_NGJ plan\Zcode\models\iNX1344_v4.xml"
21
+ GROWTH_UNITS = "mmol/gDW/h"
22
+ EPS = 1e-6
23
+
24
+ # 分类词表(name 规约匹配为主,id 规约为辅;覆盖 BiGG/ModelSEED/MetaCyc 常见命名)
25
+ _AA_NAMES = {
26
+ "l-alanine", "glycine", "l-arginine", "l-asparagine", "l-aspartate", "l-cysteine",
27
+ "l-glutamine", "l-glutamate", "l-histidine", "l-isoleucine", "l-leucine", "l-lysine",
28
+ "l-methionine", "l-phenylalanine", "l-proline", "l-serine", "l-threonine",
29
+ "l-tryptophan", "l-tyrosine", "l-valine",
30
+ }
31
+ _NUC_NAMES = {"atp", "adp", "amp", "gtp", "gdp", "gmp", "ctp", "cdp", "cmp",
32
+ "utp", "udp", "ump", "datp", "dctp", "dgtp", "dttp", "damp"}
33
+ _COFACTOR_NAMES = {"coenzyme a", "s-adenosyl-l-methionine", "10-formyltetrahydrofolate",
34
+ "5,10-methylenetetrahydrofolate", "5,6,7,8-tetrahydrofolate",
35
+ "flavin adenine dinucleotide oxidized",
36
+ "nicotinamide adenine dinucleotide",
37
+ "nicotinamide adenine dinucleotide phosphate",
38
+ "pyridoxal 5'-phosphate", "riboflavin", "thiamine diphosphate",
39
+ "menaquinol 8", "menaquinone 8", "ubiquinol-8", "ubiquinone-8",
40
+ "coenzyme b", "coenzyme m", "heme b", "siroheme", "biotin",
41
+ "tetrahydrobiopterin", "5-methyltetrahydrofolate",
42
+ "flavin mononucleotide"}
43
+ _METAL_NAMES = {"ca2+", "cl-", "co2+", "cu2+", "cu1+", "fe2+", "fe3+", "k+", "mg2+",
44
+ "mn2+", "zn2+", "ni2+", "mo6+", "mobd", "se2+", "cobalt", "copper",
45
+ "calcium", "chloride", "iron", "iron (fe3+)", "potassium", "magnesium",
46
+ "manganese", "zinc", "nickel", "sulfate"}
47
+ _LIPID_HINTS = ("undecaprenyl", "muramoyl", "lipid", "dag", "cdp-dag", "phosphatidyl",
48
+ "cardiolipin", "phosphatidylglycerol", "phosphatidylethanolamine",
49
+ "acyl-carrier", "holo-", "menaquinol", "2-oxo-3-methyl")
50
+ _OTHER_NUC_NAME_RE = re.compile(r"^(d?)(a|c|g|u|t)tp\b", re.I)
51
+
52
+
53
+ def _norm(s):
54
+ return (s or "").strip().lower()
55
+
56
+
57
+ def classify_met(met):
58
+ nm = _norm(met.name)
59
+ # 名字里可能带公式尾巴(CarveMe: "ADP C10H12N5O10P2")——取公式前段
60
+ nm0 = re.split(r"\s+[A-Z][a-z]?\d", nm)[0].strip()
61
+ if nm0 in _AA_NAMES:
62
+ return "amino_acid"
63
+ base = re.sub(r"[-_ ]?c[0ep]0?$", "", nm0)
64
+ if base in _NUC_NAMES or _OTHER_NUC_NAME_RE.match(nm0):
65
+ return "nucleotide"
66
+ if nm0 in _COFACTOR_NAMES or base in _COFACTOR_NAMES:
67
+ return "cofactor"
68
+ if nm0 in _METAL_NAMES or base in _METAL_NAMES:
69
+ return "metal_ion"
70
+ if any(h in nm0 for h in _LIPID_HINTS):
71
+ return "lipid_cellwall"
72
+ # id 兜底(BiGG 惯例)
73
+ bid = re.sub(r"[-_ ]?[cpen]0?$", "", (met.id or ""))
74
+ if bid.endswith("__L") or bid.endswith("__D"):
75
+ return "amino_acid"
76
+ if bid in _NUC_NAMES:
77
+ return "nucleotide"
78
+ return "other"
79
+
80
+
81
+ def find_biomass(m):
82
+ """FBA 目标反应(objective_coefficient != 0);多个时取组分最多的。"""
83
+ objs = [r for r in m.reactions if abs(r.objective_coefficient or 0) > 0]
84
+ if not objs:
85
+ return None, []
86
+ objs.sort(key=lambda r: -len(r.metabolites))
87
+ return objs[0], objs[1:]
88
+
89
+
90
+ def _element_totals(rxn):
91
+ from validate import parse_formula
92
+ totals = {}
93
+ for met, coeff in rxn.metabolites.items():
94
+ if not met.formula:
95
+ continue
96
+ for el, n in parse_formula(met.formula).items():
97
+ totals[el] = totals.get(el, 0.0) + abs(coeff) * n
98
+ return {k: round(v, 4) for k, v in sorted(totals.items(), key=lambda kv: -kv[1])}
99
+
100
+
101
+ def _category_dist(comps):
102
+ d = {}
103
+ for c in comps:
104
+ d[c["category"]] = d.get(c["category"], 0) + 1
105
+ return dict(sorted(d.items(), key=lambda kv: -kv[1]))
106
+
107
+
108
+ def inspect_biomass(model_path, reference=None, universal_path=None, inx_path=None):
109
+ """只读:biomass 组分摘要 + 可选参考对照。不改模型。"""
110
+ from silentio import silent_read_sbml
111
+ m = silent_read_sbml(model_path)
112
+ rxn, others = find_biomass(m)
113
+ if rxn is None:
114
+ return {"error": f"no objective reaction found in {model_path}"}
115
+ comps = []
116
+ for met, coeff in rxn.metabolites.items():
117
+ comps.append({"met_id": met.id, "name": met.name or met.id,
118
+ "coeff": round(coeff, 6), "compartment": met.compartment,
119
+ "category": classify_met(met)})
120
+ comps.sort(key=lambda c: (c["category"], c["met_id"]))
121
+ result = {
122
+ "model": model_path,
123
+ "biomass_reaction": rxn.id, "biomass_name": rxn.name or "",
124
+ "bounds": list(rxn.bounds), "objective_coefficient": rxn.objective_coefficient,
125
+ "n_components": len(comps), "units": GROWTH_UNITS,
126
+ "components": comps,
127
+ "category_distribution": _category_dist(comps),
128
+ "element_totals": _element_totals(rxn),
129
+ "other_objective_reactions": [r.id for r in others],
130
+ }
131
+ # ---- 参考对照(只读,尽力翻译;翻译不了明示 unmapped)----
132
+ refs = {}
133
+ upath = universal_path or DEFAULT_UNIVERSAL
134
+ if reference in ("iML1515", "both") and os.path.exists(upath):
135
+ um = silent_read_sbml(upath)
136
+ urxn, _ = find_biomass(um)
137
+ if urxn is not None:
138
+ urefs = [{"met_id": x.id, "name": x.name or x.id, "coeff": round(c, 6),
139
+ "category": classify_met(x)} for x, c in urxn.metabolites.items()]
140
+ refs["iML1515"] = {"biomass_reaction": urxn.id, "n_components": len(urefs),
141
+ "category_distribution": _category_dist(urefs)}
142
+ ipath = inx_path or DEFAULT_INX
143
+ if reference in ("iNX1344_v4", "both") and os.path.exists(ipath):
144
+ im = silent_read_sbml(ipath)
145
+ irxn, _ = find_biomass(im)
146
+ if irxn is not None:
147
+ icomps = [{"met_id": x.id, "name": (x.name or ""), "category": classify_met(x)}
148
+ for x, _c in irxn.metabolites.items()]
149
+ # 名字同义翻译(本模型名 ↔ iNX1344 名;未命名=unmapped)
150
+ def nkey(s):
151
+ s = re.split(r"\s+[A-Z][a-z]?\d", _norm(s))[0].strip()
152
+ return re.sub(r"[-_ ]?c[0ep]0?$", "", s)
153
+ theirs = {}
154
+ for c in icomps:
155
+ if c["name"]:
156
+ theirs.setdefault(nkey(c["name"]), c)
157
+ mapped, unmapped_inx = 0, 0
158
+ for c in comps:
159
+ if nkey(c["name"]) in theirs:
160
+ mapped += 1
161
+ else:
162
+ unmapped_inx += 1
163
+ refs["iNX1344_v4"] = {
164
+ "biomass_reaction": irxn.id, "n_components": len(icomps),
165
+ "category_distribution": _category_dist(icomps),
166
+ "translation": {"matched_by_name": mapped, "unmapped": unmapped_inx,
167
+ "note": "MetaCyc↔BiGG 按代谢物名同义尽力翻译;未命名/无同名的计入 unmapped,不强行全翻"},
168
+ }
169
+ if refs:
170
+ result["references"] = refs
171
+ return {"ok": True, "result": result}
172
+
173
+
174
+ def _essential_sample(m, resolved_med, genes, sample_size=40):
175
+ """确定性抽样敲除(免 FVA;同子集双侧对比,delta 语义成立)。返回 essential 集合。"""
176
+ all_ids = sorted(g.id for g in m.genes)
177
+ if not all_ids:
178
+ return set()
179
+ stride = max(1, len(all_ids) // max(1, sample_size))
180
+ subset = set(all_ids[::stride][:sample_size])
181
+ from l3_fix import _set_medium
182
+ out = set()
183
+ for gid in sorted(subset):
184
+ try:
185
+ with m:
186
+ m.genes.get_by_id(gid).knock_out()
187
+ _set_medium(m, resolved_med)
188
+ v = m.optimize().objective_value
189
+ except KeyError:
190
+ continue
191
+ if v is not None and v < EPS:
192
+ out.add(gid)
193
+ return out
194
+
195
+
196
+ def apply_biomass(model_path, biomass_profile, medium=None, phenotype_table=None,
197
+ out=None, essential_sample=40, note=None):
198
+ """显式覆盖表应用:[{"met_id","coeff","op":"set|add|remove"}] → 副本替换 biomass →
199
+ 强制 G1-G6 重验 + 三联对照(growth/表型/必需基因 delta)→ lineage 追加(有 card 时)。"""
200
+ from silentio import silent_read_sbml, silent_write_sbml
201
+ from validate import validate_model
202
+ from gapfind import expand_medium, resolve_medium
203
+ from model_card import append_operation, GROWTH_UNITS
204
+ if not biomass_profile:
205
+ return {"ok": False, "error": "provide biomass_profile(显式覆盖表 [{met_id, coeff, op: set|add|remove}]);"
206
+ "inspect 才是只读,默认不应用任何 profile"}
207
+ t0 = time.time()
208
+ m = silent_read_sbml(model_path)
209
+ rxn, _ = find_biomass(m)
210
+ if rxn is None:
211
+ return {"ok": False, "error": "no objective/biomass reaction found"}
212
+ med, preset = expand_medium(medium or {})
213
+ resolved_med, unresolved = resolve_medium(m, med)
214
+ table_ok = phenotype_table and os.path.exists(phenotype_table)
215
+
216
+ # ---- before(原模型、原 biomass;改动前先采对照)----
217
+ ess_before = _essential_sample(m, resolved_med, m.genes, essential_sample)
218
+ before_v = validate_model(model_path, medium=resolved_med,
219
+ phenotype_table=phenotype_table if table_ok else None,
220
+ carbon_mode="sole" if table_ok else "supplement")
221
+
222
+ # ---- 应用覆盖表(内存副本)----
223
+ applied, skipped = [], []
224
+ for p in biomass_profile:
225
+ mid, op = p.get("met_id"), (p.get("op") or "set").lower()
226
+ if mid not in m.metabolites:
227
+ skipped.append({**p, "why": "metabolite not in model"})
228
+ continue
229
+ met = m.metabolites.get_by_id(mid)
230
+ in_rxn = met in rxn.metabolites
231
+ old = rxn.metabolites.get(met)
232
+ try:
233
+ new_coeff = float(p.get("coeff"))
234
+ except (TypeError, ValueError):
235
+ skipped.append({**p, "why": "coeff missing/not numeric"})
236
+ continue
237
+ if op == "set":
238
+ if not in_rxn:
239
+ skipped.append({**p, "why": "met not in biomass(先 add)"})
240
+ continue
241
+ final = -abs(new_coeff) if old < 0 else abs(new_coeff) # 沿用原符号约定
242
+ # cobra add_metabolites 是增量语义——绝对设定必须按差值到达目标
243
+ rxn.add_metabolites({met: final - old})
244
+ elif op == "add":
245
+ if in_rxn:
246
+ skipped.append({**p, "why": "already in biomass(用 set)"})
247
+ continue
248
+ final = -abs(new_coeff)
249
+ rxn.add_metabolites({met: final})
250
+ elif op == "remove":
251
+ if not in_rxn:
252
+ skipped.append({**p, "why": "not in biomass"})
253
+ continue
254
+ final = 0.0
255
+ rxn.add_metabolites({met: -old})
256
+ else:
257
+ skipped.append({**p, "why": f"unknown op: {op}"})
258
+ continue
259
+ actual = rxn.metabolites.get(met)
260
+ applied.append({"op": op, "met_id": mid, "old": old, "new": actual})
261
+ if abs((actual or 0.0) - final) > 1e-9:
262
+ skipped.append({**p, "why": f"post-set verify failed: got {actual}, want {final}"})
263
+ if not applied:
264
+ return {"ok": False, "error": "no profile entry applied", "skipped": skipped}
265
+
266
+ # ---- 落盘 + after 重验 ----
267
+ if not out:
268
+ out = model_path[:-4] + "_bm.xml" if model_path.endswith(".xml") else model_path + "_bm.xml"
269
+ silent_write_sbml(m, out)
270
+ after_v = validate_model(out, medium=resolved_med,
271
+ phenotype_table=phenotype_table if table_ok else None,
272
+ carbon_mode="sole" if table_ok else "supplement")
273
+ ess_after = _essential_sample(m, resolved_med, m.genes, essential_sample)
274
+ lost = sorted(ess_before - ess_after)
275
+ gained = sorted(ess_after - ess_before)
276
+
277
+ growth_b = (before_v.get("g3") or {}).get("growth_medium")
278
+ growth_a = (after_v.get("g3") or {}).get("growth_medium")
279
+ warn = []
280
+ if growth_b is not None and growth_a is not None and growth_a < growth_b - 1e-6:
281
+ warn.append(f"growth decreased {growth_b:.6f} -> {growth_a:.6f}(如实报告,不阻塞)")
282
+ ph_b = before_v.get("g4") or {}
283
+ ph_a = after_v.get("g4") or {}
284
+
285
+ result = {
286
+ "biomass_reaction": rxn.id, "applied": applied, "skipped": skipped,
287
+ "out": out, "units": GROWTH_UNITS,
288
+ "before_after": {
289
+ "growth": {"before": growth_b, "after": growth_a, "units": GROWTH_UNITS,
290
+ # 阶段A-M4 口径声明(只增)
291
+ "point_value_note": "单点 FBA 值,非解空间硬结论;条件对比请用 gem_fluxscan 区间分离判定"},
292
+ "phenotype": {"before": {"matched": ph_b.get("matched"), "total": ph_b.get("total")},
293
+ "after": {"matched": ph_a.get("matched"), "total": ph_a.get("total")}},
294
+ "essential": {"sampled": min(essential_sample, len(m.genes)), "before": sorted(ess_before),
295
+ "after": sorted(ess_after), "lost": lost, "gained": gained},
296
+ "validate_before": {k: (before_v.get(k) or {}).get("status") for k in ("g1", "g2", "g3", "g6")},
297
+ "validate_after": {k: (after_v.get(k) or {}).get("status") for k in ("g1", "g2", "g3", "g4", "g6")},
298
+ },
299
+ "medium_preset": preset, "medium_unresolved": unresolved,
300
+ "warnings": warn, "elapsed_s": round(time.time() - t0, 1),
301
+ }
302
+ # lineage(原模型旁有 card 时:先传播卡到产物旁再追加,保证新模型自带完整 lineage)
303
+ from model_card import append_operation, load_card, card_path_for, propagate_card
304
+ propagate_card(model_path, out)
305
+ card = append_operation(out, "biomass_apply", reactions_added=0, reactions_removed=0,
306
+ detail={"profile_ops": applied, "growth": [growth_b, growth_a],
307
+ "essential_delta": {"lost": lost, "gained": gained},
308
+ "note": note})
309
+ result["card"] = card_path_for(out) if card else None
310
+ result["card_version"] = (card or {}).get("model_lineage", {}).get("version")
311
+ return {"ok": True, "result": result}
312
+
313
+
314
+ def card_path_for(model_path):
315
+ from model_card import card_path_for as _c
316
+ return _c(model_path)
317
+
318
+
319
+ if __name__ == "__main__":
320
+ args = json.loads(open(sys.argv[1], encoding="utf-8").read()) if len(sys.argv) > 1 else {}
321
+ if args.get("action") == "apply":
322
+ print(json.dumps(apply_biomass(args.get("model"), args.get("biomass_profile"),
323
+ medium=args.get("medium"), phenotype_table=args.get("phenotype_table"),
324
+ out=args.get("out"), essential_sample=args.get("essential_sample", 40),
325
+ note=args.get("note")), ensure_ascii=False, indent=2))
326
+ else:
327
+ print(json.dumps(inspect_biomass(args.get("model"), reference=args.get("reference"),
328
+ universal_path=args.get("universal_path"),
329
+ inx_path=args.get("inx_path")), ensure_ascii=False, indent=2))
@@ -0,0 +1,53 @@
1
+ # budget.py — 防过补第五闸门(B' 后半新增,全局预算)
2
+ # 规格: PROMPT-L3 §2「单模型补洞历史累计新增反应数 ≤ max(5, 模型总反应数*5%);
3
+ # 超限返回 budget_exceeded + confirm_required,需显式 confirm_budget=true 才能继续」。
4
+ # 计数口径: 反应 notes["source"] ∈ {gem-gapfill, gem-l3fix}(两代补洞的 provenance 约定)。
5
+ # 用法: budget_gate(m, planned=2, confirm_budget=False) -> None(放行)或 budget_exceeded dict
6
+ GAP_SOURCE_TAGS = ("gem-gapfill", "gem-l3fix")
7
+
8
+
9
+ def prior_added(m):
10
+ """模型补洞历史:带补洞 provenance 标记的反应数。"""
11
+ n = 0
12
+ for r in m.reactions:
13
+ src = (r.notes or {}).get("source")
14
+ if src in GAP_SOURCE_TAGS:
15
+ n += 1
16
+ return n
17
+
18
+
19
+ def budget_for(m):
20
+ """预算上限 = max(5, 模型总反应数 * 5%)。"""
21
+ return max(5, int(len(m.reactions) * 0.05))
22
+
23
+
24
+ def budget_gate(m, planned=0, confirm_budget=False):
25
+ """第五闸门:历史累计 + 本批 planned 是否超预算。
26
+ 返回 None = 放行;dict = 超限(调用方原样返回给协议层,含 confirm_required)。"""
27
+ hist = prior_added(m)
28
+ cap = budget_for(m)
29
+ if hist + planned <= cap:
30
+ return None
31
+ return {
32
+ "error": "budget_exceeded",
33
+ "confirm_required": True,
34
+ "prior_added": hist,
35
+ "planned": planned,
36
+ "budget": cap,
37
+ "note": "补洞历史累计新增反应已超 max(5, 5%·总反应数) 预算;"
38
+ "继续需显式传 confirm_budget=true(防过补第五闸门)",
39
+ }
40
+
41
+
42
+ if __name__ == "__main__":
43
+ import json, sys
44
+ raw = sys.stdin.read() if not sys.stdin.isatty() else (sys.argv[1] if len(sys.argv) > 1 else "{}")
45
+ args = json.loads(open(sys.argv[1], encoding="utf-8").read()) if (sys.stdin.isatty() and len(sys.argv) > 1) else json.loads(raw or "{}")
46
+ cap = max(5, int(args.get("n_reactions", 100) * 0.05))
47
+ hist = args.get("prior_added", 0)
48
+ planned = args.get("planned", 0)
49
+ if hist + planned <= cap:
50
+ print(json.dumps({"ok": True, "budget": cap, "prior_added": hist, "planned": planned}))
51
+ else:
52
+ print(json.dumps({"error": "budget_exceeded", "confirm_required": args.get("confirm_budget") is not True,
53
+ "budget": cap, "prior_added": hist, "planned": planned}))