@dsh-bio/dsh-bio-gem 0.1.12 → 0.1.13
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/README.md +251 -236
- package/docs/ARCHITECTURE.md +15 -15
- package/docs/DECISIONS-2026-09-21.md +76 -0
- package/package.json +3 -3
- package/python/benchmark.py +3 -3
- package/python/biomass_tools.py +2 -2
- package/python/bootstrap_carveme.py +259 -0
- package/python/build.py +10 -2
- package/python/double_knockout.py +1 -1
- package/python/essential_scan.py +1 -1
- package/python/gem_ops.py +45 -1
- package/python/l3_fix.py +4 -4
- package/python/ledger.py +1 -1
- package/python/model_card.py +3 -2
- package/python/precursor_scan.py +1 -1
- package/python/quality.py +577 -0
- package/python/sampling.py +269 -0
- package/python/sensitivity.py +2 -2
- package/python/validate.py +4 -4
- package/skills/gem-expert.md +2 -0
- package/src/capabilities.js +152 -0
- package/src/index.js +1 -1
- package/src/integration.js +41 -23
- package/src/tools.js +71 -14
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# DECISIONS — 2026-09-21 升级批次(外部评审裁决后 Phase 1)
|
|
2
|
+
|
|
3
|
+
> 本批决策依据:`D:\Program\dsh-plug-develop\21-gem升级-裁决与实施方案.md`(GPT/DS 双评审 + 本机 8 项实测核验)。
|
|
4
|
+
> 记录本批次的**语义演进与架构决策**,供下游与后续批次参考。
|
|
5
|
+
|
|
6
|
+
## D1. growth 单位演进:mmol/gDW/h → 1/h(数值不变)
|
|
7
|
+
|
|
8
|
+
**决策**:growth / growth_rate / wt_growth / before-after 生长 等**比生长量**字段的单位标注由
|
|
9
|
+
`mmol/gDW/h` 改为 `1/h`;反应通量类保持 `mmol/gDW/h`。
|
|
10
|
+
|
|
11
|
+
**依据**(实测 + 社区约定):
|
|
12
|
+
- C58 的 bio1(Bacterial Gram-negative biomass reaction)产物系数 = 1.0 —— biomass 反应已按
|
|
13
|
+
**1 gDW 归一化**;此时其通量数值 = 比生长速率 μ(h⁻¹),这是 COBRA 社区的标准解读
|
|
14
|
+
(biomass flux through a normalized biomass reaction equals the specific growth rate)。
|
|
15
|
+
- 外部评审(GPT)指出原标注 `mmol/gDW/h` 对 biomass 反应在语义上不准确 → 列为 P0。
|
|
16
|
+
- straindesign 官方文档同样以 `growth rates above 0.5/h` 表述(旁证)。
|
|
17
|
+
|
|
18
|
+
**边界**:对**未归一化**的 biomass 反应(产物系数 ≠ 1)growth 应以 mmol/gDW/h 解读——见
|
|
19
|
+
validate 的 units.note 表述。数值本身在任何情况下不变,本决策只纠标注。
|
|
20
|
+
|
|
21
|
+
**影响面**:python/ 12 个模块(23 处)+ src/tools.js(16 处口径文案统一)+ 本文档;
|
|
22
|
+
`model_card.GROWTH_UNITS` 同步演进(旧卡兼容:growth_units 字段照读,数值口径不变)。
|
|
23
|
+
|
|
24
|
+
## D2. 能力单源(capabilities.json → /v1/capabilities → 宿主动态消费)
|
|
25
|
+
|
|
26
|
+
**决策**:工具清单与能力元数据(cost_class / network / mutability / requires)以
|
|
27
|
+
`src/capabilities.js` 的 `TOOLS_MANIFEST` 为**唯一事实源**:
|
|
28
|
+
- integration API 新增 `GET /v1/capabilities`(features 声明 `capabilities`);
|
|
29
|
+
- 宿主 dsh-bio-genie 的 `handleDomainRequest` 在对方声明该 feature 时拉取并透传
|
|
30
|
+
(失败静默降级到静态清单 `GEM_TOOLS`——它已降级为 fallback 视图);
|
|
31
|
+
- 机器门:`test/check-capabilities.mjs`(manifest ↔ 真实注册一致)+
|
|
32
|
+
`test/check-counts.mjs`(文档计数 ↔ manifest 一致)。
|
|
33
|
+
|
|
34
|
+
**背景**:原「工具数变化需同步 ≥12 处」是两评审共同指出的工程债;本决策把它变成
|
|
35
|
+
「单源 + 两道机器门」,文档数字仍手写但有门兜底。
|
|
36
|
+
|
|
37
|
+
## D3. CarveMe 零手动部署(bootstrap_carveme.py)
|
|
38
|
+
|
|
39
|
+
**决策**:`gem_build(engine=carveme)` 首次调用自动完成运行时部署(幂等):
|
|
40
|
+
uv venv(uv 探测链:`GEM_UV` → genie 自举 uv → PATH)→ `uv pip install carveme` →
|
|
41
|
+
下载 GitHub 官方 diamond 二进制(**固定 v2.2.8**,3.4MB)→ deep smoke → manifest 记录。
|
|
42
|
+
|
|
43
|
+
**关键设计**:
|
|
44
|
+
- **快速路径**:`carve.exe + diamond.exe + manifest.json` 齐备时秒过(实测 0.34s,不做子进程冒烟);
|
|
45
|
+
- 部署与复验才做 deep smoke(实测全量部署 45s:venv 31s + diamond 下载数秒 + 冒烟);
|
|
46
|
+
- 下载通道:直连 → 环境代理(HTTPS_PROXY/HTTP_PROXY)→ 失败给可执行指引(含手动放置路径);
|
|
47
|
+
- 这就是 P0「装完插件 ≠ 构建可用」的根治:契约要求用户零手动安装。
|
|
48
|
+
|
|
49
|
+
## D4. gem_sample 的边界设计(全空间 vs 受限)
|
|
50
|
+
|
|
51
|
+
**决策**:默认采样**全 feasible space**(ACHR),但 `boundary` 字段**强制输出**边界声明;
|
|
52
|
+
近最优生长状态必须显式传 `growth_floor_fraction`(如 0.9)。
|
|
53
|
+
|
|
54
|
+
**依据**(本机实测):C58 全空间采样 bio1 max ≈ 0.017 vs FBA 最优 0.7134(差 2 个数量级)——
|
|
55
|
+
全空间均匀分布 ≠ 生物学上有意义的活跃状态;不声明边界就是「做了但没用」的典型。
|
|
56
|
+
|
|
57
|
+
**Windows 约束**:默认 ACHR(无多进程依赖,实测 init ~172s、采样秒级);OptGP 在 Windows
|
|
58
|
+
下实验性——不可用时**显式安全拒绝**(不触发多进程陷阱)。
|
|
59
|
+
|
|
60
|
+
## D5. quality_index(gem-qi-v1)不是 MEMOTE 分数
|
|
61
|
+
|
|
62
|
+
**决策**:`gem_quality` 输出 `quality_index`(0-100 启发式聚合)+ 分项 raw_metrics +
|
|
63
|
+
failed_checks + not_assessable_checks;**禁止**作为单一质量结论引用(notes 内置声明)。
|
|
64
|
+
|
|
65
|
+
**依据**:两评审共识——MEMOTE 的价值在其可分解测试体系;聚合分容易被误用。
|
|
66
|
+
评分规则(写死在 `python/quality.py` 常量):见 WEIGHTS / 阈值常量与 note 输出。
|
|
67
|
+
**不安装 MEMOTE 整体**(PyPI 0.17.0 仅声明兼容到 Py3.11;本机 Py3.13 风险)。
|
|
68
|
+
|
|
69
|
+
## D6. 本批新增工具(2)
|
|
70
|
+
|
|
71
|
+
| 工具 | capability | cost_class | 说明 |
|
|
72
|
+
|---|---|---|---|
|
|
73
|
+
| `gem_quality` | `gem.validate.quality-report` | medium | 质量审计(blocked/环路/平衡/孤儿/覆盖/连通性)|
|
|
74
|
+
| `gem_sample` | `gem.analysis.sampling` | heavy | 通量空间采样(ACHR/OptGP;边界声明)|
|
|
75
|
+
|
|
76
|
+
工具数 21 → 23;op 数 21 → 23。两门(check-capabilities / check-counts)同步更新。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dsh-bio/dsh-bio-gem",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.13",
|
|
4
4
|
"description": "基因组尺度代谢模型(GEM)构建插件:输入细菌全基因组(蛋白FASTA,支持多质粒/多染色体),自动构建+验证+补洞+出报告(SBML + 模型卡),供 dsh-bio-genie 消费工具加载使用 | Genome-scale metabolic model builder for dsh",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -40,8 +40,8 @@
|
|
|
40
40
|
},
|
|
41
41
|
"scripts": {
|
|
42
42
|
"smoke": "node test/smoke.js",
|
|
43
|
-
"test": "node test/smoke.js --skip-build && node test/integration.js && node --import ./test/register-dsh-tools.mjs test/optional-injection.js",
|
|
44
|
-
"test:full": "node test/smoke.js && node test/integration.js && node --import ./test/register-dsh-tools.mjs test/optional-injection.js"
|
|
43
|
+
"test": "node test/smoke.js --skip-build && node test/integration.js && node --import ./test/register-dsh-tools.mjs test/optional-injection.js && node --import ./test/register-dsh-tools.mjs test/check-capabilities.mjs && node test/check-counts.mjs",
|
|
44
|
+
"test:full": "node test/smoke.js && node test/integration.js && node --import ./test/register-dsh-tools.mjs test/optional-injection.js && node --import ./test/register-dsh-tools.mjs test/check-capabilities.mjs && node test/check-counts.mjs"
|
|
45
45
|
},
|
|
46
46
|
"peerDependencies": {
|
|
47
47
|
"@deepseek-ai/dsh-tools": "*"
|
package/python/benchmark.py
CHANGED
|
@@ -19,7 +19,7 @@ from sensitivity import find_biomass_gam
|
|
|
19
19
|
from validate import Validator
|
|
20
20
|
|
|
21
21
|
EPS = 1e-6
|
|
22
|
-
UNITS_NOTE = "growth=
|
|
22
|
+
UNITS_NOTE = "growth=1/h(比生长速率;biomass 归一化口径,数值 = μ);必需判定=敲除生长<1e-6"
|
|
23
23
|
DEG_MSG = "wt<=EPS:必需性判定恒真(v=0 使全部候选判'必需'),essential 集无生物学意义"
|
|
24
24
|
|
|
25
25
|
|
|
@@ -317,7 +317,7 @@ def write_md(path, out):
|
|
|
317
317
|
L.append("\n## 3. 生长(声明介质,单点 FBA 口径)\n")
|
|
318
318
|
for tag, name in (("a", "A"), ("b", "B")):
|
|
319
319
|
g = out["growth"][tag]
|
|
320
|
-
L.append(f"- **{name}**: growth=**{g['growth']}**
|
|
320
|
+
L.append(f"- **{name}**: growth=**{g['growth']}** 1/h, resolved={g['resolved_exchanges']}, "
|
|
321
321
|
f"boundary_style={g['boundary_style']}, unresolved={g['unresolved']}")
|
|
322
322
|
if g.get("resolved_display"):
|
|
323
323
|
L.append(f" - resolved_display: {g['resolved_display']}")
|
|
@@ -492,7 +492,7 @@ def benchmark(model_a, model_b, medium=None, phenotype_table=None, reference_ess
|
|
|
492
492
|
for tag, path in (("a", model_a), ("b", model_b)):
|
|
493
493
|
wt, resolved, unresolved, preset, bstyle = growth_on(path, medium)
|
|
494
494
|
entry = {"growth": wt, "resolved_exchanges": len(resolved), "unresolved": unresolved,
|
|
495
|
-
"medium_preset": preset, "boundary_style": bstyle, "units": "
|
|
495
|
+
"medium_preset": preset, "boundary_style": bstyle, "units": "1/h",
|
|
496
496
|
"point_value_note": "单点 FBA 值,非解空间硬结论;条件间对比用 gem_fluxscan(区间制)"}
|
|
497
497
|
if bstyle:
|
|
498
498
|
entry["resolved_display"] = [ex_display_name(silent_read_sbml(path), rid)
|
package/python/biomass_tools.py
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
# apply(显式): biomass_profile 覆盖表(op=set|add|remove)→ 副本替换 biomass → 强制 G1-G6 重验
|
|
5
5
|
# + 三联对照(生长/表型/必需基因 delta)→ model_lineage 追加(有 card 时)
|
|
6
6
|
# 原则: 默认不应用任何 profile;生长变差 WARN 不阻塞;C58 CarveMe AB 0.624 锚点保护(delta 如实报告)。
|
|
7
|
-
# units: growth 一律
|
|
7
|
+
# units: growth 一律 1/h(比生长速率;biomass 反应 gDW 归一化口径,数值 = μ)。
|
|
8
8
|
import os
|
|
9
9
|
import re
|
|
10
10
|
import sys
|
|
@@ -18,7 +18,7 @@ import cobra
|
|
|
18
18
|
EX_PREFIX = ("EX_", "DM_", "SK_")
|
|
19
19
|
DEFAULT_UNIVERSAL = r"D:\Program\hermes\temp\gem_universal\iML1515.xml"
|
|
20
20
|
DEFAULT_INX = r"F:\A_NGJ plan\Zcode\models\iNX1344_v4.xml"
|
|
21
|
-
GROWTH_UNITS = "
|
|
21
|
+
GROWTH_UNITS = "1/h"
|
|
22
22
|
EPS = 1e-6
|
|
23
23
|
|
|
24
24
|
# 分类词表(name 规约匹配为主,id 规约为辅;覆盖 BiGG/ModelSEED/MetaCyc 常见命名)
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
# bootstrap_carveme.py — CarveMe 运行时「零手动部署」(探测 → uv venv → carveme → diamond → 冒烟 → manifest)
|
|
2
|
+
#
|
|
3
|
+
# 产品原则:用户零手动安装。gem_build(engine=carveme) 首次调用时自动补齐运行时:
|
|
4
|
+
# 1. 探测现有 venv(carve.exe + diamond.exe + 冒烟通过)→ 就绪则秒回
|
|
5
|
+
# 2. 缺则部署:uv venv(uv 探测链见下)→ uv pip install carveme → 下载 diamond 官方二进制 → 冒烟
|
|
6
|
+
# 3. 全过程写进度事件(progress.jsonl),失败给「可执行指引」而不是裸异常
|
|
7
|
+
#
|
|
8
|
+
# uv 探测链:GEM_UV env → genie 自举 uv(~/.dsh/dsh-bio-genie/bin/uv.exe)→ PATH uv
|
|
9
|
+
# diamond 来源:GitHub release 固定版本(v2.2.8, diamond-windows.zip, 3.4MB 纯二进制)
|
|
10
|
+
# 下载策略:直连 → 环境变量代理(HTTPS_PROXY/HTTP_PROXY)→ 失败给指引
|
|
11
|
+
#
|
|
12
|
+
# 幂等性:重复调用不重复部署;diamond 单独缺失时只补 diamond;manifest.json 记录版本与来源
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import shutil
|
|
16
|
+
import subprocess
|
|
17
|
+
import sys
|
|
18
|
+
import time
|
|
19
|
+
import urllib.request
|
|
20
|
+
import zipfile
|
|
21
|
+
|
|
22
|
+
DEFAULT_CARVE_VENV = os.path.join(os.path.expanduser("~"), ".dsh", "dsh-bio-gem", "venv-carveme")
|
|
23
|
+
GEM_ROOT = os.path.join(os.path.expanduser("~"), ".dsh", "dsh-bio-gem")
|
|
24
|
+
DIAMOND_VERSION = "v2.2.8"
|
|
25
|
+
DIAMOND_URL = f"https://github.com/bbuchfink/diamond/releases/download/{DIAMOND_VERSION}/diamond-windows.zip"
|
|
26
|
+
GENIE_UV = os.path.join(os.path.expanduser("~"), ".dsh", "dsh-bio-genie", "bin", "uv.exe")
|
|
27
|
+
|
|
28
|
+
VERIFY_TIMEOUT = 60
|
|
29
|
+
DOWNLOAD_TIMEOUT = 300
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _log(progress_path, event):
|
|
33
|
+
"""进度事件(与 build.py 同格式;progress_path 为 None 时静默)。"""
|
|
34
|
+
if not progress_path:
|
|
35
|
+
return
|
|
36
|
+
try:
|
|
37
|
+
ev = {"ts": time.time(), **event}
|
|
38
|
+
with open(progress_path, "a", encoding="utf-8") as f:
|
|
39
|
+
f.write(json.dumps(ev, ensure_ascii=False) + "\n")
|
|
40
|
+
except Exception:
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _run(cmd, timeout=VERIFY_TIMEOUT, env=None):
|
|
45
|
+
return subprocess.run(cmd, capture_output=True, text=True, timeout=timeout,
|
|
46
|
+
env=env, encoding="utf-8", errors="replace")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def find_uv():
|
|
50
|
+
"""uv 探测链:GEM_UV → genie 自举 → PATH。返回 (uv_path, source) 或 (None, None)。"""
|
|
51
|
+
env_uv = os.environ.get("GEM_UV")
|
|
52
|
+
if env_uv and os.path.exists(env_uv):
|
|
53
|
+
return env_uv, "env:GEM_UV"
|
|
54
|
+
if os.path.exists(GENIE_UV):
|
|
55
|
+
return GENIE_UV, "genie-bootstrap"
|
|
56
|
+
which = shutil.which("uv")
|
|
57
|
+
if which:
|
|
58
|
+
return which, "PATH"
|
|
59
|
+
return None, None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _carve_exe(venv=None):
|
|
63
|
+
return os.path.join(venv or DEFAULT_CARVE_VENV, "Scripts", "carve.exe")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _diamond_exe(venv=None):
|
|
67
|
+
return os.path.join(venv or DEFAULT_CARVE_VENV, "Scripts", "diamond.exe")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _probe(venv=None, deep=False):
|
|
71
|
+
"""就绪探测。
|
|
72
|
+
deep=False(默认):文件存在 + manifest 信任 —— 秒级(gem_build 每次调用的日常路径);
|
|
73
|
+
deep=True:额外跑 diamond/carve 冒烟(部署完成后复验一次用)。
|
|
74
|
+
返回 (ok, detail)。"""
|
|
75
|
+
carve, diamond = _carve_exe(venv), _diamond_exe(venv)
|
|
76
|
+
if not os.path.exists(carve):
|
|
77
|
+
return False, {"missing": "carve.exe"}
|
|
78
|
+
if not os.path.exists(diamond):
|
|
79
|
+
return False, {"missing": "diamond.exe"}
|
|
80
|
+
if not deep and _read_manifest(venv):
|
|
81
|
+
return True, {"mode": "fast-manifest"}
|
|
82
|
+
try:
|
|
83
|
+
r1 = _run([diamond, "--version"])
|
|
84
|
+
if r1.returncode != 0:
|
|
85
|
+
return False, {"diamond_broken": (r1.stderr or r1.stdout or "")[:200]}
|
|
86
|
+
# carve 无 --version:用 -h 验证可启动(exit 0)
|
|
87
|
+
r2 = _run([carve, "-h"])
|
|
88
|
+
if r2.returncode not in (0, 1): # argparse -h 正常为 0;某些版本非 0
|
|
89
|
+
return False, {"carve_broken": (r2.stderr or r2.stdout or "")[:200]}
|
|
90
|
+
return True, {"diamond": (r1.stdout or "").strip()[:80], "mode": "deep-smoke"}
|
|
91
|
+
except Exception as e:
|
|
92
|
+
return False, {"probe_error": f"{type(e).__name__}: {e}"}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _download(url, dest, progress_path=None):
|
|
96
|
+
"""下载(直连 → 环境代理 → 抛错含指引)。返回使用的通道。"""
|
|
97
|
+
headers = {"User-Agent": "dsh-bio-gem-bootstrap/1.0"}
|
|
98
|
+
|
|
99
|
+
def _get(opener=None):
|
|
100
|
+
req = urllib.request.Request(url, headers=headers)
|
|
101
|
+
op = opener.open(req, timeout=DOWNLOAD_TIMEOUT) if opener else urllib.request.urlopen(req, timeout=DOWNLOAD_TIMEOUT)
|
|
102
|
+
with op as resp, open(dest, "wb") as f:
|
|
103
|
+
shutil.copyfileobj(resp, f)
|
|
104
|
+
|
|
105
|
+
try:
|
|
106
|
+
_get()
|
|
107
|
+
return "direct"
|
|
108
|
+
except Exception as e1:
|
|
109
|
+
_log(progress_path, {"event": "diamond_direct_failed", "err": str(e1)[:200]})
|
|
110
|
+
proxy = (os.environ.get("HTTPS_PROXY") or os.environ.get("https_proxy")
|
|
111
|
+
or os.environ.get("HTTP_PROXY") or os.environ.get("http_proxy"))
|
|
112
|
+
if proxy:
|
|
113
|
+
try:
|
|
114
|
+
opener = urllib.request.build_opener(
|
|
115
|
+
urllib.request.ProxyHandler({"http": proxy, "https": proxy}))
|
|
116
|
+
_get(opener)
|
|
117
|
+
return f"proxy:{proxy}"
|
|
118
|
+
except Exception as e2:
|
|
119
|
+
_log(progress_path, {"event": "diamond_proxy_failed", "err": str(e2)[:200]})
|
|
120
|
+
raise RuntimeError(
|
|
121
|
+
f"无法下载 diamond({url})。可执行的手动方案:\n"
|
|
122
|
+
f" 1) 设置代理后重试:set HTTPS_PROXY=http://127.0.0.1:端口\n"
|
|
123
|
+
f" 2) 或手动下载 diamond-windows.zip,把解压出的 diamond.exe 放到:\n"
|
|
124
|
+
f" {os.path.join(DEFAULT_CARVE_VENV, 'Scripts')}")
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def ensure_carveme(venv=None, progress_path=None, force_diamond=False):
|
|
128
|
+
"""确保 CarveMe 运行时可用(幂等)。返回 dict:
|
|
129
|
+
{ready, action: already|deployed|repaired|failed, carve, diamond, version, note?, error?}
|
|
130
|
+
"""
|
|
131
|
+
venv = venv or DEFAULT_CARVE_VENV
|
|
132
|
+
ok, detail = _probe(venv)
|
|
133
|
+
if ok and not force_diamond:
|
|
134
|
+
return {"ready": True, "action": "already", "carve": _carve_exe(venv),
|
|
135
|
+
"diamond": _diamond_exe(venv), "note": detail.get("diamond", "")}
|
|
136
|
+
|
|
137
|
+
_log(progress_path, {"event": "bootstrap_start", "venv": venv, "probe": detail})
|
|
138
|
+
os.makedirs(os.path.dirname(venv), exist_ok=True)
|
|
139
|
+
|
|
140
|
+
uv, uv_src = find_uv()
|
|
141
|
+
if not uv:
|
|
142
|
+
return {"ready": False, "action": "failed", "error":
|
|
143
|
+
"未找到 uv(探测链:GEM_UV env → ~/.dsh/dsh-bio-genie/bin/uv.exe → PATH)。"
|
|
144
|
+
"请安装 uv(https://docs.astral.sh/uv/)或安装宿主插件 dsh-bio-genie 以复用其自举 uv。"}
|
|
145
|
+
|
|
146
|
+
# ---- 1) venv + carveme(carve.exe 缺失或损坏时) ----
|
|
147
|
+
need_env = not os.path.exists(_carve_exe(venv))
|
|
148
|
+
if need_env:
|
|
149
|
+
_log(progress_path, {"event": "uv_venv", "uv": uv, "uv_source": uv_src})
|
|
150
|
+
try:
|
|
151
|
+
r = _run([uv, "venv", venv, "--python", "3.13"], timeout=300)
|
|
152
|
+
if r.returncode != 0 or not os.path.exists(os.path.join(venv, "Scripts", "python.exe")):
|
|
153
|
+
return {"ready": False, "action": "failed",
|
|
154
|
+
"error": f"uv venv 创建失败 rc={r.returncode}: {(r.stderr or '')[-300:]}"}
|
|
155
|
+
except Exception as e:
|
|
156
|
+
return {"ready": False, "action": "failed", "error": f"uv venv 异常: {e}"}
|
|
157
|
+
|
|
158
|
+
py = os.path.join(venv, "Scripts", "python.exe")
|
|
159
|
+
_log(progress_path, {"event": "uv_pip_carveme", "detail": "uv pip install carveme"})
|
|
160
|
+
try:
|
|
161
|
+
r = _run([uv, "pip", "install", "--python", py, "carveme"], timeout=900)
|
|
162
|
+
if r.returncode != 0:
|
|
163
|
+
return {"ready": False, "action": "failed",
|
|
164
|
+
"error": f"carveme 安装失败 rc={r.returncode}: {(r.stderr or '')[-400:]}"}
|
|
165
|
+
except Exception as e:
|
|
166
|
+
return {"ready": False, "action": "failed", "error": f"carveme 安装异常: {e}"}
|
|
167
|
+
|
|
168
|
+
# ---- 2) diamond(缺失/损坏时补) ----
|
|
169
|
+
if not os.path.exists(_diamond_exe(venv)) or force_diamond:
|
|
170
|
+
scripts_dir = os.path.join(venv, "Scripts")
|
|
171
|
+
os.makedirs(scripts_dir, exist_ok=True)
|
|
172
|
+
tmp_zip = os.path.join(venv, "diamond-download.zip")
|
|
173
|
+
_log(progress_path, {"event": "diamond_download", "url": DIAMOND_URL})
|
|
174
|
+
try:
|
|
175
|
+
channel = _download(DIAMOND_URL, tmp_zip, progress_path)
|
|
176
|
+
except RuntimeError as e:
|
|
177
|
+
return {"ready": False, "action": "failed", "error": str(e)}
|
|
178
|
+
try:
|
|
179
|
+
with zipfile.ZipFile(tmp_zip) as z:
|
|
180
|
+
names = z.namelist()
|
|
181
|
+
if "diamond.exe" not in names:
|
|
182
|
+
return {"ready": False, "action": "failed",
|
|
183
|
+
"error": f"diamond zip 内容异常: {names}"}
|
|
184
|
+
z.extract("diamond.exe", scripts_dir)
|
|
185
|
+
except Exception as e:
|
|
186
|
+
return {"ready": False, "action": "failed", "error": f"diamond 解压失败: {e}"}
|
|
187
|
+
finally:
|
|
188
|
+
try:
|
|
189
|
+
os.remove(tmp_zip)
|
|
190
|
+
except OSError:
|
|
191
|
+
pass
|
|
192
|
+
_log(progress_path, {"event": "diamond_ready", "channel": channel})
|
|
193
|
+
|
|
194
|
+
# ---- 3) 冒烟复验(deep:真跑 diamond/carve 一次) ----
|
|
195
|
+
ok2, detail2 = _probe(venv, deep=True)
|
|
196
|
+
version = _read_carve_version(venv)
|
|
197
|
+
manifest = {
|
|
198
|
+
"carve_version": version,
|
|
199
|
+
"diamond_version": DIAMOND_VERSION,
|
|
200
|
+
"diamond_url": DIAMOND_URL,
|
|
201
|
+
"uv_source": uv_src,
|
|
202
|
+
"installed_at": time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
203
|
+
}
|
|
204
|
+
try:
|
|
205
|
+
with open(os.path.join(venv, "manifest.json"), "w", encoding="utf-8") as f:
|
|
206
|
+
json.dump(manifest, f, ensure_ascii=False, indent=1)
|
|
207
|
+
except OSError:
|
|
208
|
+
pass
|
|
209
|
+
|
|
210
|
+
if not ok2:
|
|
211
|
+
return {"ready": False, "action": "failed",
|
|
212
|
+
"error": f"部署后冒烟未通过: {detail2}"}
|
|
213
|
+
action = "deployed" if need_env else "repaired"
|
|
214
|
+
_log(progress_path, {"event": "bootstrap_done", "action": action, "manifest": manifest})
|
|
215
|
+
return {"ready": True, "action": action, "carve": _carve_exe(venv),
|
|
216
|
+
"diamond": _diamond_exe(venv), "manifest": manifest}
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _read_carve_version(venv=None):
|
|
220
|
+
py = os.path.join(venv or DEFAULT_CARVE_VENV, "Scripts", "python.exe")
|
|
221
|
+
if not os.path.exists(py):
|
|
222
|
+
return None
|
|
223
|
+
try:
|
|
224
|
+
r = _run([py, "-c",
|
|
225
|
+
"import importlib.metadata as im; print(im.version('carveme'))"])
|
|
226
|
+
if r.returncode == 0:
|
|
227
|
+
return (r.stdout or "").strip()
|
|
228
|
+
except Exception:
|
|
229
|
+
pass
|
|
230
|
+
return None
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def carveme_status(venv=None):
|
|
234
|
+
"""只读状态(供诊断/工具面板)。"""
|
|
235
|
+
ok, detail = _probe(venv)
|
|
236
|
+
return {"ready": ok, "venv": venv or DEFAULT_CARVE_VENV,
|
|
237
|
+
"carve": _carve_exe(venv), "diamond": _diamond_exe(venv),
|
|
238
|
+
"detail": detail, "manifest": _read_manifest(venv),
|
|
239
|
+
"uv": find_uv()[1]}
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _read_manifest(venv=None):
|
|
243
|
+
p = os.path.join(venv or DEFAULT_CARVE_VENV, "manifest.json")
|
|
244
|
+
try:
|
|
245
|
+
with open(p, encoding="utf-8") as f:
|
|
246
|
+
return json.load(f)
|
|
247
|
+
except Exception:
|
|
248
|
+
return None
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
if __name__ == "__main__":
|
|
252
|
+
# CLI:python bootstrap_carveme.py [status|ensure]
|
|
253
|
+
action = sys.argv[1] if len(sys.argv) > 1 else "ensure"
|
|
254
|
+
if action == "status":
|
|
255
|
+
print(json.dumps(carveme_status(), ensure_ascii=False, indent=1))
|
|
256
|
+
else:
|
|
257
|
+
out = ensure_carveme()
|
|
258
|
+
print(json.dumps(out, ensure_ascii=False, indent=1))
|
|
259
|
+
sys.exit(0 if out.get("ready") else 1)
|
package/python/build.py
CHANGED
|
@@ -138,6 +138,14 @@ def build(input_spec, name=None, medium=None, venv=None, out_dir=None, progress_
|
|
|
138
138
|
os.makedirs(out_dir, exist_ok=True)
|
|
139
139
|
out_xml = os.path.join(out_dir, name + ".xml")
|
|
140
140
|
|
|
141
|
+
# 0) 运行时自举(零手动安装):carve + diamond 缺失时自动部署(幂等,就绪时秒过)
|
|
142
|
+
from bootstrap_carveme import ensure_carveme
|
|
143
|
+
boot = ensure_carveme(venv=venv, progress_path=progress_path)
|
|
144
|
+
if not boot.get("ready"):
|
|
145
|
+
_log(progress_path, {"event": "bootstrap_failed", "error": boot.get("error")})
|
|
146
|
+
raise RuntimeError("CarveMe 运行时不可用:" + str(boot.get("error")))
|
|
147
|
+
_log(progress_path, {"event": "bootstrap_ok", "action": boot.get("action")})
|
|
148
|
+
|
|
141
149
|
# 1) carve(自带 M9 gapfill,CarveMe 原生最小培养基)
|
|
142
150
|
st = time.time()
|
|
143
151
|
if not (os.path.exists(out_xml) and os.path.getmtime(out_xml) > os.path.getmtime(proteins)):
|
|
@@ -195,7 +203,7 @@ def build(input_spec, name=None, medium=None, venv=None, out_dir=None, progress_
|
|
|
195
203
|
# 4) 模型卡(schema v2 起步:supported_mediums 由验证结果得出)
|
|
196
204
|
supported = [
|
|
197
205
|
{"medium_name": "M9", "ex_reactions": sorted(med_m9),
|
|
198
|
-
"growth_rate": g3_m9.get("growth_medium"), "units": "
|
|
206
|
+
"growth_rate": g3_m9.get("growth_medium"), "units": "1/h",
|
|
199
207
|
"validation_status": "verified_G3" if g3_m9.get("status") == "PASS" else "unverified"},
|
|
200
208
|
]
|
|
201
209
|
if target and target.get("g3") == "PASS":
|
|
@@ -204,7 +212,7 @@ def build(input_spec, name=None, medium=None, venv=None, out_dir=None, progress_
|
|
|
204
212
|
tname = medium.get("medium_name") or "custom"
|
|
205
213
|
supported.append({
|
|
206
214
|
"medium_name": tname, "ex_reactions": target.get("resolved_exchanges"),
|
|
207
|
-
"growth_rate": target.get("growth"), "units": "
|
|
215
|
+
"growth_rate": target.get("growth"), "units": "1/h",
|
|
208
216
|
"validation_status": "verified_G3_G4" if target.get("gapfixes_applied", 0) == 0 else "verified_G3_only",
|
|
209
217
|
})
|
|
210
218
|
# 模型卡(schema v2:init_card 统一基座 —— lineage v0.1.0 起始 + changelog=[build])
|
|
@@ -63,7 +63,7 @@ def double_knockout(model_path, medium=None, max_pairs=5000, export_csv=None,
|
|
|
63
63
|
log(f"[dk] {model_path} medium={medium} wt={wt} max_pairs={max_pairs}")
|
|
64
64
|
|
|
65
65
|
out = {"model": model_path, "medium": medium, "medium_preset": preset,
|
|
66
|
-
"wt_growth": wt, "units": "
|
|
66
|
+
"wt_growth": wt, "units": "1/h", "assumption_note": ASSUMPTION_NOTE,
|
|
67
67
|
"max_pairs": max_pairs, "eps": EPS,
|
|
68
68
|
"degenerate": wt <= EPS}
|
|
69
69
|
if out["degenerate"]:
|
package/python/essential_scan.py
CHANGED
|
@@ -151,7 +151,7 @@ def essential_scan(model_path, medium=None, gene_subset=None, progress=None, led
|
|
|
151
151
|
"medium_unresolved": unresolved,
|
|
152
152
|
"note": "必需判定=A 培养基下敲除生长<1e-6;evidence 分级按基因支撑反应是否含 EVIDENCE_math(Q2)",
|
|
153
153
|
# 阶段A-M4 口径声明(只增):wt_growth 为单点 FBA 值
|
|
154
|
-
"units": "
|
|
154
|
+
"units": "1/h",
|
|
155
155
|
"point_value_note": "单点 FBA 值,非解空间硬结论;条件对比请用 gem_fluxscan 区间分离判定",
|
|
156
156
|
})
|
|
157
157
|
# 模型卡 schema v2 回写(产物旁已有 card 才写;无卡不凭空造卡)
|
package/python/gem_ops.py
CHANGED
|
@@ -309,6 +309,48 @@ def op_sensitivity(args):
|
|
|
309
309
|
export_csv=args.get("export_csv"), baseline_check=baseline_check)}
|
|
310
310
|
|
|
311
311
|
|
|
312
|
+
# ---------------------------------------------------------------------------
|
|
313
|
+
# op: quality — gem-qi-v1 模型质量摘要(只读)
|
|
314
|
+
# ---------------------------------------------------------------------------
|
|
315
|
+
def op_quality(args):
|
|
316
|
+
from quality import quality_report
|
|
317
|
+
model = args.get("model")
|
|
318
|
+
if not model or not os.path.exists(model):
|
|
319
|
+
return {"ok": False, "error": f"model file not found: {model}"}
|
|
320
|
+
try:
|
|
321
|
+
result = quality_report(
|
|
322
|
+
model, medium=args.get("medium"), checks=args.get("checks"),
|
|
323
|
+
export_csv=args.get("export_csv"))
|
|
324
|
+
except (ValueError, OSError) as e:
|
|
325
|
+
return {"ok": False, "error": str(e)}
|
|
326
|
+
return {"ok": True, "result": result}
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
# ---------------------------------------------------------------------------
|
|
330
|
+
# op: sample — COBRA 通量空间采样(默认 Windows-safe ACHR)
|
|
331
|
+
# ---------------------------------------------------------------------------
|
|
332
|
+
def op_sample(args):
|
|
333
|
+
from sampling import sample_fluxes
|
|
334
|
+
model = args.get("model")
|
|
335
|
+
if not model or not os.path.exists(model):
|
|
336
|
+
return {"ok": False, "error": f"model file not found: {model}"}
|
|
337
|
+
try:
|
|
338
|
+
result = sample_fluxes(
|
|
339
|
+
model,
|
|
340
|
+
medium=args.get("medium"),
|
|
341
|
+
n=args.get("n", 1000),
|
|
342
|
+
method=args.get("method", "auto"),
|
|
343
|
+
thinning=args.get("thinning", 100),
|
|
344
|
+
growth_floor_fraction=args.get("growth_floor_fraction"),
|
|
345
|
+
reactions=args.get("reactions"),
|
|
346
|
+
seed=args.get("seed", 42),
|
|
347
|
+
export_csv=args.get("export_csv"),
|
|
348
|
+
)
|
|
349
|
+
except (ValueError, OSError) as e:
|
|
350
|
+
return {"ok": False, "error": str(e)}
|
|
351
|
+
return {"ok": True, "result": result}
|
|
352
|
+
|
|
353
|
+
|
|
312
354
|
# ---------------------------------------------------------------------------
|
|
313
355
|
# 分发器
|
|
314
356
|
# ---------------------------------------------------------------------------
|
|
@@ -323,6 +365,8 @@ OPS = {
|
|
|
323
365
|
"annotate": op_annotate,
|
|
324
366
|
"media_resolve": op_media_resolve,
|
|
325
367
|
"l3_fix": op_l3_fix,
|
|
368
|
+
"quality": op_quality,
|
|
369
|
+
"sample": op_sample,
|
|
326
370
|
}
|
|
327
371
|
|
|
328
372
|
|
|
@@ -536,4 +580,4 @@ def main():
|
|
|
536
580
|
|
|
537
581
|
|
|
538
582
|
if __name__ == "__main__":
|
|
539
|
-
main()
|
|
583
|
+
main()
|
package/python/l3_fix.py
CHANGED
|
@@ -417,7 +417,7 @@ def l3_fix(model_path, medium=None, substrates=None, out=None,
|
|
|
417
417
|
if exid and exid in m.reactions and g < 1e-6:
|
|
418
418
|
l3.append({"substrate": sub, "exchange": exid, "growth_sole_before": round(g, 6),
|
|
419
419
|
# 阶段A-M4 口径声明(只增)
|
|
420
|
-
"units": "
|
|
420
|
+
"units": "1/h",
|
|
421
421
|
"point_value_note": "单点 FBA 值,非解空间硬结论;条件对比请用 gem_fluxscan 区间分离判定"})
|
|
422
422
|
|
|
423
423
|
# 第五闸门(入口预检: 每底物至少 1 条新增预估)
|
|
@@ -493,7 +493,7 @@ def l3_fix(model_path, medium=None, substrates=None, out=None,
|
|
|
493
493
|
"evidence": "EVIDENCE_math"})
|
|
494
494
|
growth_a = _growth_sole(cur, resolved_med, exid)
|
|
495
495
|
l3a["growth_sole_after"] = round(growth_a, 6)
|
|
496
|
-
l3a["units"] = "
|
|
496
|
+
l3a["units"] = "1/h"
|
|
497
497
|
l3a["point_value_note"] = "单点 FBA 值,非解空间硬结论;条件对比请用 gem_fluxscan 区间分离判定"
|
|
498
498
|
entry["l3a"] = l3a
|
|
499
499
|
|
|
@@ -556,12 +556,12 @@ def l3_fix(model_path, medium=None, substrates=None, out=None,
|
|
|
556
556
|
_note(f"[l3b] {sub}: picked {len(picked)}, added {len(added_here)}")
|
|
557
557
|
growth_b = _growth_sole(cur, resolved_med, exid)
|
|
558
558
|
l3b["growth_sole_after"] = round(growth_b, 6)
|
|
559
|
-
l3b["units"] = "
|
|
559
|
+
l3b["units"] = "1/h"
|
|
560
560
|
l3b["point_value_note"] = "单点 FBA 值,非解空间硬结论;条件对比请用 gem_fluxscan 区间分离判定"
|
|
561
561
|
l3b["added"] = added_here
|
|
562
562
|
entry["l3b"] = l3b
|
|
563
563
|
entry["growth_sole_after"] = round(max(growth_a, growth_b), 6)
|
|
564
|
-
entry["units"] = "
|
|
564
|
+
entry["units"] = "1/h"
|
|
565
565
|
entry["point_value_note"] = "单点 FBA 值,非解空间硬结论;条件对比请用 gem_fluxscan 区间分离判定"
|
|
566
566
|
entry["verdict"] = "fixed" if growth_b > 1e-6 else "not_fixable"
|
|
567
567
|
if entry["verdict"] == "not_fixable":
|
package/python/ledger.py
CHANGED
|
@@ -401,7 +401,7 @@ def register_phenotype(model_path, g4_results, condition=None, lineage_version=N
|
|
|
401
401
|
"type": "phenotype",
|
|
402
402
|
"content": (f"底物 {sub} 预测{'生长' if r.get('predicted') else '不生长'}"
|
|
403
403
|
f"(文献={r.get('published')},匹配={r.get('match')},"
|
|
404
|
-
f"growth={r.get('growth')}
|
|
404
|
+
f"growth={r.get('growth')} 1/h)"),
|
|
405
405
|
"model": model_path,
|
|
406
406
|
"model_lineage_version": lineage_version,
|
|
407
407
|
"condition": condition,
|
package/python/model_card.py
CHANGED
|
@@ -3,14 +3,15 @@
|
|
|
3
3
|
# / set_verified_phenotypes(phenotype 结果)/ set_essential_genes(必需基因 + 证据分级)
|
|
4
4
|
# 纪律: 各工具完成后**仅当产物模型旁已有 card** 才向后追加;无卡不动(不凭空造卡)。
|
|
5
5
|
# 兼容: build.py 旧卡(无 schema 字段)读取时即时迁移到 v2(新增字段缺失不报错)。
|
|
6
|
-
# units: growth_rate 一律
|
|
6
|
+
# units: growth_rate 一律 1/h(比生长速率;biomass 反应 gDW 归一化口径,数值 = μ)。
|
|
7
|
+
# 2026-09-21 由 mmol/gDW/h 演进为 1/h:数值不变、生物语义更准(外部评审 P0;schema 向后兼容,旧卡照读)。
|
|
7
8
|
import os
|
|
8
9
|
import json
|
|
9
10
|
import time
|
|
10
11
|
|
|
11
12
|
CARD_SUFFIX = ".card.json"
|
|
12
13
|
SCHEMA = "v2"
|
|
13
|
-
GROWTH_UNITS = "
|
|
14
|
+
GROWTH_UNITS = "1/h"
|
|
14
15
|
|
|
15
16
|
|
|
16
17
|
def _now():
|
package/python/precursor_scan.py
CHANGED
|
@@ -73,7 +73,7 @@ def scan_precursors(model_path, medium=None, max_precursors=200):
|
|
|
73
73
|
"medium_applied_exchanges": medium_applied,
|
|
74
74
|
"medium_unresolved": unresolved,
|
|
75
75
|
"baseline_flux": round(baseline, 6),
|
|
76
|
-
"units": "
|
|
76
|
+
"units": "1/h",
|
|
77
77
|
"point_value_note": "单点 FBA 值,非解空间硬结论",
|
|
78
78
|
"blocking_precursors": [],
|
|
79
79
|
"n_blocking": 0,
|