@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
package/python/l3_fix.py
ADDED
|
@@ -0,0 +1,641 @@
|
|
|
1
|
+
# l3_fix.py — B' 后半:L3 内部路径补洞(两级)
|
|
2
|
+
# L3a 模型内连通性: 先"全内部反应放开方向"LP 预检(快速严谨判负),可行才用 cobra GapFiller
|
|
3
|
+
# universal=模型自身反应池(放开方向副本,NEW id),MILP 选最小集 → 对原反应放宽 bounds(不复制反应)。
|
|
4
|
+
# 注: cobra 0.32.1 GapFiller(universal=None) 语义是"空反应池"(只加 demand),不是"模型自身反应池"
|
|
5
|
+
# ——故本实现显式构造自身反应池(2026-08-29 读 cobra 源码确认)。
|
|
6
|
+
# L3b 白名单 + BiGG 反应式: 白名单命中集(build_whitelist B0/B1,缓存 ~/.dsh/dsh-bio-gem/whitelist/)
|
|
7
|
+
# → MetaCyc rxn ID 桥(EC 号/名字规约 + gapseq all-Reactions.tbl 增强)→ iML1515 反应式
|
|
8
|
+
# → 代谢物移植(名字规约匹配 -> COFACTOR_BRIDGE 静态桥[公式/电荷校验] -> 随反应引入新代谢物)。
|
|
9
|
+
# 无匹配不强补。PTS 型反应一律排除(Rhizobiaceae 等 PTS-less 机体守则)。
|
|
10
|
+
# 证据分级: EVIDENCE_sequence(白名单桥接)/ EVIDENCE_math(LP/MILP 连通性,最弱);
|
|
11
|
+
# L1/L2 规则补洞为 EVIDENCE_rule(见 gapfill.py)。notes["evidence"] + notes["source"]=gem-l3fix。
|
|
12
|
+
# 防过补第五闸门: budget.py(累计新增 ≤ max(5, 5%·总反应)),超限 confirm_required=true 才放行。
|
|
13
|
+
# 补后重验: validate G1-G6 全跑;G6 WARN/FAIL → 回滚本批全部改动(删新增反应 + 还原 bounds)。
|
|
14
|
+
# 协议: stdout 仅最后一行 JSON(进度走 stderr);-I 隔离模式 sys.path 显式插入。
|
|
15
|
+
import os
|
|
16
|
+
import re
|
|
17
|
+
import sys
|
|
18
|
+
import json
|
|
19
|
+
import time
|
|
20
|
+
import hashlib
|
|
21
|
+
|
|
22
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
23
|
+
|
|
24
|
+
import cobra
|
|
25
|
+
from cobra.flux_analysis.gapfilling import GapFiller
|
|
26
|
+
|
|
27
|
+
EX_PREFIX = ("EX_", "DM_", "SK_")
|
|
28
|
+
SOURCE_TAG = "gem-l3fix"
|
|
29
|
+
NEW_RXN_SUFFIX = "_l3fix"
|
|
30
|
+
|
|
31
|
+
# BiGG 基名 -> ModelSEED cpd 号(2026-08-29 本机以 C58 名字+公式+电荷逐一验证;
|
|
32
|
+
# 映射时仍做公式/电荷校验,不一致即弃用防静默污染化学计量)
|
|
33
|
+
COFACTOR_BRIDGE = {
|
|
34
|
+
"h2o": "00001", "atp": "00002", "nad": "00003", "nadh": "00004",
|
|
35
|
+
"nadp": "00005", "nadph": "00006", "o2": "00007", "adp": "00008",
|
|
36
|
+
"pi": "00009", "coa": "00010", "co2": "00011", "nh4": "00013",
|
|
37
|
+
"glu__L": "00023", "akg": "00024", "gln__L": "00053", "pyr": "00020",
|
|
38
|
+
"accoa": "00022", "succ": "00036", "so4": "00048", "pep": "00061",
|
|
39
|
+
"h": "00067", "f6p": "00072", "g6p": "00079", "e4p": "00236",
|
|
40
|
+
"mal__L": "00130", "r5p": "00101", "ru5p": "00171", "xu5p": "00198",
|
|
41
|
+
}
|
|
42
|
+
COMP_MAP = {"c": "c0", "e": "e0", "p": "p0"} # BiGG 区室后缀 -> gapseq 区室 id
|
|
43
|
+
|
|
44
|
+
WHITELIST_DIR = os.path.join(os.path.expanduser("~"), ".dsh", "dsh-bio-gem", "whitelist")
|
|
45
|
+
# 本地白名单数据库(license 守则: 仅本机,不进 git/发布包;GEM_WHITELIST_DB_DIR 可覆盖)
|
|
46
|
+
RXN_DB_DIR = os.environ.get("GEM_WHITELIST_DB_DIR", r"D:\Program\hermes\temp\gem_whitelist")
|
|
47
|
+
DEFAULT_UNIVERSAL = r"D:\Program\hermes\temp\gem_universal\iML1515.xml"
|
|
48
|
+
|
|
49
|
+
PTS_RE = re.compile(r"(?i)\bpts\b|phosphotransferase|pep:pyr")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _note(*a):
|
|
53
|
+
print(*a, file=sys.stderr)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def norm_name(s):
|
|
57
|
+
return "".join(ch for ch in (s or "").lower() if ch.isalnum())
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def met_name_key(met):
|
|
61
|
+
"""代谢物名规约键:去常见区室尾巴(-c0/-c/-e0 等)后小写字母数字。"""
|
|
62
|
+
nm = (met.name or "").strip()
|
|
63
|
+
nm = re.sub(r"[-_ ]?c[0ep]0?$", "", nm, flags=re.I)
|
|
64
|
+
return norm_name(nm)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _bigg_base_comp(met_id):
|
|
68
|
+
mm = re.match(r"^(.*)__?([cepn])$", met_id)
|
|
69
|
+
if mm:
|
|
70
|
+
return mm.group(1), mm.group(2)
|
|
71
|
+
parts = met_id.rsplit("_", 1)
|
|
72
|
+
return (parts[0], "c") if len(parts) == 2 and parts[1] in COMP_MAP else (met_id, "c")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _formula_ok(met_a, met_b):
|
|
76
|
+
fa, fb = (met_a.formula or "").replace(" ", ""), (met_b.formula or "").replace(" ", "")
|
|
77
|
+
if fa and fb and fa.upper() != fb.upper():
|
|
78
|
+
return False
|
|
79
|
+
if met_a.charge is not None and met_b.charge is not None and met_a.charge != met_b.charge:
|
|
80
|
+
return False
|
|
81
|
+
return True
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def build_met_name_index(m):
|
|
85
|
+
idx = {}
|
|
86
|
+
for x in m.metabolites:
|
|
87
|
+
idx.setdefault(met_name_key(x), []).append(x)
|
|
88
|
+
return idx
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _has_carbon(formula):
|
|
92
|
+
"""元素级含碳判断(裸子串会把 Ca/Cl/Co/Cu 误判为含碳——2026-08-29 实测氯缺失致 sole 全灭)。"""
|
|
93
|
+
from validate import parse_formula
|
|
94
|
+
return "C" in parse_formula(formula or "")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def sole_medium(m, resolved_med, ex_id, lb=-10.0):
|
|
98
|
+
"""唯一碳源语义: 基底介质去掉含碳交换 + 目标底物交换打开。返回 {EX_id: lb}。"""
|
|
99
|
+
med = {}
|
|
100
|
+
for rid, v in (resolved_med or {}).items():
|
|
101
|
+
if rid in m.reactions:
|
|
102
|
+
met = list(m.reactions.get_by_id(rid).metabolites)[0]
|
|
103
|
+
if met.formula and _has_carbon(met.formula):
|
|
104
|
+
continue
|
|
105
|
+
med[rid] = v
|
|
106
|
+
if ex_id:
|
|
107
|
+
med[ex_id] = lb
|
|
108
|
+
return med
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _set_medium(m, med):
|
|
112
|
+
for r in m.reactions:
|
|
113
|
+
if r.id.startswith(EX_PREFIX) or r.boundary:
|
|
114
|
+
r.lower_bound = 0.0
|
|
115
|
+
for rid, v in (med or {}).items():
|
|
116
|
+
if rid in m.reactions:
|
|
117
|
+
m.reactions.get_by_id(rid).lower_bound = v
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _growth_sole(m, resolved_med, ex_id, lb=-10.0):
|
|
121
|
+
with m:
|
|
122
|
+
_set_medium(m, sole_medium(m, resolved_med, ex_id, lb))
|
|
123
|
+
return m.optimize().objective_value or 0.0
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _relax_all_internal(m):
|
|
127
|
+
for r in m.reactions:
|
|
128
|
+
if r.id.startswith(EX_PREFIX) or r.boundary:
|
|
129
|
+
continue
|
|
130
|
+
r.bounds = (-1000.0, 1000.0)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _rollback(cur, applied_all, relaxed_all):
|
|
134
|
+
"""回滚本批改动: 删除本批新增反应(孤儿代谢物一并回收)+ 还原放宽的 bounds。"""
|
|
135
|
+
if applied_all:
|
|
136
|
+
cur.remove_reactions([cur.reactions.get_by_id(a["rxn"]) for a in applied_all
|
|
137
|
+
if a["rxn"] in cur.reactions], remove_orphans=True)
|
|
138
|
+
for rb in relaxed_all:
|
|
139
|
+
r0 = cur.reactions.get_by_id(rb["rxn"])
|
|
140
|
+
r0.bounds = tuple(rb["old_bounds"])
|
|
141
|
+
r0.notes.pop("bound_relaxed_by", None)
|
|
142
|
+
r0.notes.pop("evidence", None)
|
|
143
|
+
return cur
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _hop_reactions(m, ex_id, hops=2):
|
|
147
|
+
"""底物交换反应的邻域(k 跳反应-代谢物二部图),限定 L3a MILP 规模。"""
|
|
148
|
+
if ex_id not in m.reactions:
|
|
149
|
+
return set()
|
|
150
|
+
seen_r = {ex_id}
|
|
151
|
+
frontier_m = {x.id for x in m.reactions.get_by_id(ex_id).metabolites}
|
|
152
|
+
seen_m = set(frontier_m)
|
|
153
|
+
for _ in range(hops):
|
|
154
|
+
nxt_r = set()
|
|
155
|
+
for mid in frontier_m:
|
|
156
|
+
nxt_r |= {r.id for r in m.metabolites.get_by_id(mid).reactions}
|
|
157
|
+
nxt_r -= seen_r
|
|
158
|
+
seen_r |= nxt_r
|
|
159
|
+
frontier_m = set()
|
|
160
|
+
for rid in nxt_r:
|
|
161
|
+
frontier_m |= {x.id for x in m.reactions.get_by_id(rid).metabolites}
|
|
162
|
+
frontier_m -= seen_m
|
|
163
|
+
seen_m |= frontier_m
|
|
164
|
+
return seen_r
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
# ---------------------------------------------------------------------------
|
|
168
|
+
# 白名单加载与缓存(重复调用免重跑 diamond)
|
|
169
|
+
# ---------------------------------------------------------------------------
|
|
170
|
+
def _sha1_file(path, chunk=1 << 20):
|
|
171
|
+
h = hashlib.sha1()
|
|
172
|
+
with open(path, "rb") as f:
|
|
173
|
+
for b in iter(lambda: f.read(chunk), b""):
|
|
174
|
+
h.update(b)
|
|
175
|
+
return h.hexdigest()
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def load_whitelist(species=None, faa=None, whitelist_json=None, force_rebuild=False):
|
|
179
|
+
"""白名单命中集 {rxn_id: [seq_ids]}。
|
|
180
|
+
优先级: whitelist_json(现成命中集导入+缓存)> 缓存(faa 内容哈希键)> 现场 diamond(B1)。
|
|
181
|
+
缓存: ~/.dsh/dsh-bio-gem/whitelist/<species>-<sha1[:12]>.json。"""
|
|
182
|
+
os.makedirs(WHITELIST_DIR, exist_ok=True)
|
|
183
|
+
if whitelist_json and os.path.exists(whitelist_json):
|
|
184
|
+
key = _sha1_file(whitelist_json)[:12]
|
|
185
|
+
species = species or os.path.splitext(os.path.basename(whitelist_json))[0]
|
|
186
|
+
cache = os.path.join(WHITELIST_DIR, f"{species}-{key}.json")
|
|
187
|
+
if os.path.exists(cache) and not force_rebuild:
|
|
188
|
+
with open(cache, encoding="utf-8") as f:
|
|
189
|
+
return json.load(f)["rxn_hits"], cache, {"source": "cache"}
|
|
190
|
+
with open(whitelist_json, encoding="utf-8") as f:
|
|
191
|
+
raw = json.load(f)
|
|
192
|
+
rxn_hits = raw.get("rxn_hits", raw) if isinstance(raw, dict) else {}
|
|
193
|
+
json.dump({"species": species, "sha1": key, "built_at": time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
194
|
+
"source": whitelist_json, "rxn_hits": rxn_hits},
|
|
195
|
+
open(cache, "w", encoding="utf-8"), ensure_ascii=False)
|
|
196
|
+
return rxn_hits, cache, {"source": f"imported:{whitelist_json}"}
|
|
197
|
+
if not faa or not os.path.exists(faa):
|
|
198
|
+
raise FileNotFoundError("whitelist 需要 faa(目标蛋白 fasta)或 whitelist_json(现成命中集)")
|
|
199
|
+
species = species or os.path.splitext(os.path.basename(faa))[0]
|
|
200
|
+
key = _sha1_file(faa)[:12]
|
|
201
|
+
cache = os.path.join(WHITELIST_DIR, f"{species}-{key}.json")
|
|
202
|
+
if os.path.exists(cache) and not force_rebuild:
|
|
203
|
+
with open(cache, encoding="utf-8") as f:
|
|
204
|
+
return json.load(f)["rxn_hits"], cache, {"source": "cache"}
|
|
205
|
+
from build_whitelist import diamond_whitelist
|
|
206
|
+
db_dir = RXN_DB_DIR if os.path.isdir(RXN_DB_DIR) else WHITELIST_DIR
|
|
207
|
+
r = diamond_whitelist(faa, out_dir=WHITELIST_DIR, db_path=os.path.join(db_dir, "rxn_all.dmnd"),
|
|
208
|
+
rxn_fa=os.path.join(db_dir, "rxn_all.fa"))
|
|
209
|
+
json.dump({"species": species, "faa": faa, "sha1": key,
|
|
210
|
+
"built_at": time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
211
|
+
"params": {"evalue": r.get("evalue"), "min_bitscore": r.get("min_bitscore")},
|
|
212
|
+
"rxn_hits": r["rxn_hits"]},
|
|
213
|
+
open(cache, "w", encoding="utf-8"), ensure_ascii=False)
|
|
214
|
+
return r["rxn_hits"], cache, {"source": f"diamond:{r.get('hits_tsv')}"}
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
# ---------------------------------------------------------------------------
|
|
218
|
+
# ID 桥: 白名单 rxn(MetaCyc 风格)-> iML1515 反应
|
|
219
|
+
# ---------------------------------------------------------------------------
|
|
220
|
+
def _parse_tbl(tbl_path):
|
|
221
|
+
"""gapseq all-Reactions.tbl: rxn(MetaCyc id) -> {name, ec}。"""
|
|
222
|
+
out = {}
|
|
223
|
+
if not tbl_path or not os.path.exists(tbl_path):
|
|
224
|
+
return out
|
|
225
|
+
import csv
|
|
226
|
+
with open(tbl_path, encoding="utf-8", errors="ignore") as f:
|
|
227
|
+
rd = csv.DictReader((ln for ln in f if not ln.startswith("#")), delimiter="\t")
|
|
228
|
+
for row in rd:
|
|
229
|
+
rid = (row.get("rxn") or "").strip()
|
|
230
|
+
if not rid:
|
|
231
|
+
continue
|
|
232
|
+
d = out.setdefault(rid, {"name": "", "ec": set()})
|
|
233
|
+
if row.get("name") and not d["name"]:
|
|
234
|
+
d["name"] = row["name"].strip()
|
|
235
|
+
if row.get("ec"):
|
|
236
|
+
d["ec"].add(row["ec"].strip())
|
|
237
|
+
return out
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def bridge_iML1515(rxn_ids, universal, tbl=None):
|
|
241
|
+
"""白名单 rxn id -> iML1515 反应候选。
|
|
242
|
+
规则: ① id 即 EC(如 1.1.1.127-RXN)→ universal EC 注释;② id 规约名 == universal 反应名规约;
|
|
243
|
+
③ tbl(gapseq 判定表)补 rxn->EC。返回 {wl_id: {"rxns": [uid], "rule": str}}。"""
|
|
244
|
+
name_idx, ec_idx = {}, {}
|
|
245
|
+
for r in universal.reactions:
|
|
246
|
+
name_idx.setdefault(norm_name(r.name), []).append(r.id)
|
|
247
|
+
for ec in ((r.annotation or {}).get("ec-code") or []):
|
|
248
|
+
ec_idx.setdefault(ec, []).append(r.id)
|
|
249
|
+
bridged = {}
|
|
250
|
+
for rid in rxn_ids:
|
|
251
|
+
cands, rule = set(), None
|
|
252
|
+
mm = re.match(r"^(\d+(?:\.\d+)+)-RXN$", rid)
|
|
253
|
+
ecs = {mm.group(1)} if mm else set()
|
|
254
|
+
if rid in tbl:
|
|
255
|
+
ecs |= {e for e in tbl[rid]["ec"] if re.match(r"^\d+(\.\d+)+$", e)}
|
|
256
|
+
for ec in ecs:
|
|
257
|
+
got = ec_idx.get(ec)
|
|
258
|
+
if got:
|
|
259
|
+
cands |= set(got)
|
|
260
|
+
rule = rule or f"EC {ec}"
|
|
261
|
+
got = name_idx.get(norm_name(rid.replace("-RXN", "")))
|
|
262
|
+
if got:
|
|
263
|
+
cands |= set(got)
|
|
264
|
+
rule = f"{rule}; name" if rule else "name"
|
|
265
|
+
if cands:
|
|
266
|
+
bridged[rid] = {"rxns": sorted(cands), "rule": rule}
|
|
267
|
+
return bridged
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def port_reaction(u_rxn, target_m, met_idx, new_met_cache):
|
|
271
|
+
"""iML1515 反应 -> 目标模型命名空间(不直接改 target)。
|
|
272
|
+
代谢物三层解析: 名字规约唯一匹配(公式校验) -> COFACTOR_BRIDGE(公式校验) -> 新代谢物(共享缓存)。
|
|
273
|
+
返回 (reaction_or_None, info)。"""
|
|
274
|
+
if PTS_RE.search(u_rxn.name or "") or PTS_RE.search(u_rxn.id):
|
|
275
|
+
return None, {"skipped": "PTS route excluded (PTS-less organism guard)"}
|
|
276
|
+
|
|
277
|
+
def _resolve(x):
|
|
278
|
+
base, comp = _bigg_base_comp(x.id)
|
|
279
|
+
comp_id = COMP_MAP.get(comp, comp)
|
|
280
|
+
cands = [c for c in met_idx.get(met_name_key(x), []) if c.compartment == comp_id]
|
|
281
|
+
if len(cands) == 1 and _formula_ok(x, cands[0]):
|
|
282
|
+
return cands[0], "name"
|
|
283
|
+
cpd = COFACTOR_BRIDGE.get(base)
|
|
284
|
+
if cpd:
|
|
285
|
+
tid = f"cpd{cpd}_{comp_id}"
|
|
286
|
+
if tid in target_m.metabolites:
|
|
287
|
+
tgt = target_m.metabolites.get_by_id(tid)
|
|
288
|
+
# 桥表条目经人工核验;BiGG/ModelSEED 质子记账惯例不同(H/±1 常见),
|
|
289
|
+
# 故此处不做严格公式断言,只记录差异供审计(公式完全不同族才拒收:碳数必须一致)
|
|
290
|
+
fx, ft = (x.formula or ""), (tgt.formula or "")
|
|
291
|
+
cx, ct = re.findall(r"C(\d+)", fx), re.findall(r"C(\d+)", ft)
|
|
292
|
+
if cx and ct and cx[0] != ct[0]:
|
|
293
|
+
return None, "cofactor_carbon_mismatch"
|
|
294
|
+
if not _formula_ok(x, tgt):
|
|
295
|
+
mismatches.append({"u": x.id, "t": tgt.id, "u_formula": fx, "t_formula": ft})
|
|
296
|
+
return tgt, "cofactor_bridge"
|
|
297
|
+
key = f"{base}_{comp_id}"
|
|
298
|
+
if key in target_m.metabolites:
|
|
299
|
+
key += "_l3fix" # 同名 id 已被占用(名字匹配失败=语义不同)→ 造独立副本
|
|
300
|
+
if key not in new_met_cache:
|
|
301
|
+
nm = cobra.Metabolite(key, name=(x.name or base), compartment=comp_id,
|
|
302
|
+
formula=x.formula, charge=x.charge)
|
|
303
|
+
nm.notes["source"] = SOURCE_TAG
|
|
304
|
+
new_met_cache[key] = nm
|
|
305
|
+
return new_met_cache[key], "new"
|
|
306
|
+
|
|
307
|
+
mapped, hows, new_ids, mismatches = {}, {}, [], []
|
|
308
|
+
for x in u_rxn.metabolites:
|
|
309
|
+
tgt, how = _resolve(x)
|
|
310
|
+
if tgt is None:
|
|
311
|
+
return None, {"skipped": f"cofactor mapping failed: {x.id}"}
|
|
312
|
+
mapped[x.id] = tgt
|
|
313
|
+
hows[x.id] = how
|
|
314
|
+
if how == "new":
|
|
315
|
+
new_ids.append(tgt.id)
|
|
316
|
+
if not mapped:
|
|
317
|
+
return None, {"skipped": "no metabolite mappable"}
|
|
318
|
+
n_existing = sum(1 for v in mapped.values() if v.id in target_m.metabolites)
|
|
319
|
+
if n_existing == 0:
|
|
320
|
+
return None, {"skipped": "fully novel subnet (shares no model metabolite)"}
|
|
321
|
+
# 强制下限钳 0:补洞候选必须是"可选反应"(如 ATPM 的 lb=6.86 强制维持能会污染
|
|
322
|
+
# G3 无碳/全关检查与后续底物复测——2026-08-29 实测把甘露醇 sole 测出 -0.045)
|
|
323
|
+
lb, ub = u_rxn.lower_bound, u_rxn.upper_bound
|
|
324
|
+
lb_clamped = lb > 0
|
|
325
|
+
r = cobra.Reaction(u_rxn.id + NEW_RXN_SUFFIX, name=(u_rxn.name or u_rxn.id),
|
|
326
|
+
lower_bound=min(lb, 0.0), upper_bound=ub)
|
|
327
|
+
r.add_metabolites({mapped[x.id]: c for x, c in u_rxn.metabolites.items()})
|
|
328
|
+
return r, {"n_mapped": n_existing, "n_new": len(new_ids), "new_ids": new_ids,
|
|
329
|
+
"hows": hows, "lb_clamped": lb_clamped}
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def build_pool(universal, target_m, bridged, allow_math):
|
|
333
|
+
"""L3b 候选池: 桥接(白名单序列证据)+(可选)全 universal 数学池。
|
|
334
|
+
证据口径: 白名单 id 自身 EC 型(X.X.X.X-RXN)或名字直配 → EVIDENCE_sequence;
|
|
335
|
+
经 tbl 间接 EC 桥(如 XYLISOM-RXN→ARAI 的双 EC 注释链)降级 EVIDENCE_math + sequence_hint
|
|
336
|
+
(防证据虚高——命中的是 A 酶序列、加的是 B 酶方程)。
|
|
337
|
+
返回 (pool_model, seq_backed_ids, port_report, new_met_cache)。"""
|
|
338
|
+
met_idx = build_met_name_index(target_m)
|
|
339
|
+
wl_uids = {u for v in bridged.values() for u in v["rxns"]}
|
|
340
|
+
|
|
341
|
+
def _strict_seq(wl_id, rule):
|
|
342
|
+
if rule == "name":
|
|
343
|
+
return True
|
|
344
|
+
if rule and rule.startswith("EC") and re.match(r"^\d+(\.\d+)+-RXN$", wl_id):
|
|
345
|
+
return True
|
|
346
|
+
return False
|
|
347
|
+
|
|
348
|
+
strict_uids = {u for w, v in bridged.items() for u in v["rxns"] if _strict_seq(w, v["rule"])}
|
|
349
|
+
hint_uids = wl_uids - strict_uids
|
|
350
|
+
cand_uids = set(wl_uids)
|
|
351
|
+
if allow_math:
|
|
352
|
+
cand_uids |= {r.id for r in universal.reactions
|
|
353
|
+
if not r.boundary and not r.id.startswith(EX_PREFIX)}
|
|
354
|
+
pool_rxns, new_met_cache = [], {}
|
|
355
|
+
report = {"bridged": len(bridged), "ported": 0, "excluded_pts": 0, "skipped": []}
|
|
356
|
+
for uid in sorted(cand_uids):
|
|
357
|
+
if uid not in universal.reactions:
|
|
358
|
+
continue
|
|
359
|
+
r, info = port_reaction(universal.reactions.get_by_id(uid), target_m, met_idx, new_met_cache)
|
|
360
|
+
if r is None:
|
|
361
|
+
if "PTS" in (info.get("skipped") or ""):
|
|
362
|
+
report["excluded_pts"] += 1
|
|
363
|
+
elif len(report["skipped"]) < 12:
|
|
364
|
+
report["skipped"].append({"rxn": uid, "why": info.get("skipped")})
|
|
365
|
+
continue
|
|
366
|
+
pool_rxns.append(r)
|
|
367
|
+
report["ported"] += 1
|
|
368
|
+
pool_model = cobra.Model("l3b_pool")
|
|
369
|
+
if pool_rxns:
|
|
370
|
+
pool_model.add_reactions(pool_rxns)
|
|
371
|
+
n = len(NEW_RXN_SUFFIX)
|
|
372
|
+
seq_backed = {r.id for r in pool_rxns if r.id[:-n] in strict_uids} if pool_rxns else set()
|
|
373
|
+
seq_hinted = {r.id for r in pool_rxns if r.id[:-n] in hint_uids} if pool_rxns else set()
|
|
374
|
+
return pool_model, seq_backed, report, new_met_cache, seq_hinted
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
# ---------------------------------------------------------------------------
|
|
378
|
+
# 主流程
|
|
379
|
+
# ---------------------------------------------------------------------------
|
|
380
|
+
def l3_fix(model_path, medium=None, substrates=None, out=None,
|
|
381
|
+
allow_math=False, confirm_budget=False,
|
|
382
|
+
whitelist=None, faa=None, species=None, max_iter=1, universal_path=None):
|
|
383
|
+
from silentio import silent_read_sbml, silent_write_sbml
|
|
384
|
+
from validate import validate_model
|
|
385
|
+
from gapfind import expand_medium, resolve_medium, match_ex, build_ex_index
|
|
386
|
+
from budget import budget_gate, prior_added, budget_for
|
|
387
|
+
|
|
388
|
+
t0 = time.time()
|
|
389
|
+
if not substrates:
|
|
390
|
+
return {"ok": False, "error": "substrates required(L3 由底物驱动诊断)"}
|
|
391
|
+
m = silent_read_sbml(model_path)
|
|
392
|
+
med, preset_name = expand_medium(medium or {})
|
|
393
|
+
resolved_med, unresolved = resolve_medium(m, med)
|
|
394
|
+
ex_idx = build_ex_index(m)
|
|
395
|
+
|
|
396
|
+
# 白名单(L3b 用;不可用不阻塞 L3a,记录原因)
|
|
397
|
+
rxn_hits, wl_cache, wl_src = None, None, None
|
|
398
|
+
try:
|
|
399
|
+
rxn_hits, wl_cache, wl_src = load_whitelist(species=species, faa=faa, whitelist_json=whitelist)
|
|
400
|
+
_note(f"[whitelist] {len(rxn_hits)} rxn hits via {wl_src['source']}")
|
|
401
|
+
except Exception as e:
|
|
402
|
+
_note(f"[whitelist] unavailable: {e}")
|
|
403
|
+
|
|
404
|
+
universal = None
|
|
405
|
+
upath = universal_path or DEFAULT_UNIVERSAL
|
|
406
|
+
if (rxn_hits or allow_math) and os.path.exists(upath):
|
|
407
|
+
universal = silent_read_sbml(upath)
|
|
408
|
+
_note(f"[universal] {upath}: {len(universal.reactions)} reactions")
|
|
409
|
+
tbl = _parse_tbl(os.path.join(os.path.dirname(model_path),
|
|
410
|
+
os.path.splitext(os.path.basename(model_path))[0] + "-all-Reactions.tbl"))
|
|
411
|
+
|
|
412
|
+
# L3 诊断(sole 语义)
|
|
413
|
+
l3 = []
|
|
414
|
+
for sub in substrates:
|
|
415
|
+
exid = match_ex(sub, ex_idx)
|
|
416
|
+
g = _growth_sole(m, resolved_med, exid)
|
|
417
|
+
if exid and exid in m.reactions and g < 1e-6:
|
|
418
|
+
l3.append({"substrate": sub, "exchange": exid, "growth_sole_before": round(g, 6),
|
|
419
|
+
# 阶段A-M4 口径声明(只增)
|
|
420
|
+
"units": "mmol/gDW/h",
|
|
421
|
+
"point_value_note": "单点 FBA 值,非解空间硬结论;条件对比请用 gem_fluxscan 区间分离判定"})
|
|
422
|
+
|
|
423
|
+
# 第五闸门(入口预检: 每底物至少 1 条新增预估)
|
|
424
|
+
gate0 = budget_gate(m, planned=len(l3), confirm_budget=confirm_budget)
|
|
425
|
+
if gate0 and not rxn_hits and not allow_math:
|
|
426
|
+
return {"ok": False, **gate0}
|
|
427
|
+
|
|
428
|
+
bridged = {}
|
|
429
|
+
if rxn_hits and universal is not None:
|
|
430
|
+
bridged = bridge_iML1515(
|
|
431
|
+
[k for k in rxn_hits if k.endswith("-RXN") or re.match(r"^\d+(\.\d+)+-RXN$", k)
|
|
432
|
+
or k.startswith("RXN-")],
|
|
433
|
+
universal, tbl)
|
|
434
|
+
_note(f"[bridge] {len(bridged)} whitelist rxn ids -> iML1515")
|
|
435
|
+
uid_to_wlid = {u: w for w, v in bridged.items() for u in v["rxns"]}
|
|
436
|
+
|
|
437
|
+
pool_model, seq_backed, port_report, new_met_cache, seq_hinted = cobra.Model("l3b_pool"), set(), {
|
|
438
|
+
"bridged": 0, "ported": 0, "excluded_pts": 0, "skipped": []}, {}, set()
|
|
439
|
+
if universal is not None:
|
|
440
|
+
pool_model, seq_backed, port_report, new_met_cache, seq_hinted = build_pool(
|
|
441
|
+
universal, m, bridged, allow_math)
|
|
442
|
+
_note(f"[pool] {len(pool_model.reactions)} ported candidates "
|
|
443
|
+
f"(sequence-backed {len(seq_backed)}, seq-hint {len(seq_hinted)}, "
|
|
444
|
+
f"pts-excluded {port_report['excluded_pts']})")
|
|
445
|
+
|
|
446
|
+
results, applied_all, relaxed_all = [], [], []
|
|
447
|
+
cur = m
|
|
448
|
+
rolled = False
|
|
449
|
+
for item in l3:
|
|
450
|
+
sub, exid = item["substrate"], item["exchange"]
|
|
451
|
+
entry = dict(item)
|
|
452
|
+
growth_before = _growth_sole(cur, resolved_med, exid)
|
|
453
|
+
|
|
454
|
+
# ---- L3a: 模型内连通性(LP 预检 → MILP 放宽 bounds)----
|
|
455
|
+
l3a = {"attempted": True}
|
|
456
|
+
scratch = cur.copy()
|
|
457
|
+
_set_medium(scratch, sole_medium(scratch, resolved_med, exid))
|
|
458
|
+
_relax_all_internal(scratch)
|
|
459
|
+
g_relax = scratch.optimize().objective_value or 0.0
|
|
460
|
+
l3a["lp_relax_growth"] = round(g_relax, 6)
|
|
461
|
+
if g_relax < 1e-6:
|
|
462
|
+
l3a["verdict"] = "not_fixable_in_model(全内部放开方向仍不生长→内部路径缺失,需外部反应式)"
|
|
463
|
+
else:
|
|
464
|
+
hop_rids = _hop_reactions(cur, exid, hops=2)
|
|
465
|
+
uni = cobra.Model("self_universal")
|
|
466
|
+
copies = []
|
|
467
|
+
for rid in sorted(hop_rids):
|
|
468
|
+
r0 = cur.reactions.get_by_id(rid)
|
|
469
|
+
cp = cobra.Reaction("l3a_" + rid, name=r0.name,
|
|
470
|
+
lower_bound=-1000.0, upper_bound=1000.0)
|
|
471
|
+
cp.add_metabolites(dict(r0.metabolites))
|
|
472
|
+
copies.append(cp)
|
|
473
|
+
uni.add_reactions(copies)
|
|
474
|
+
mc = cur.copy()
|
|
475
|
+
_set_medium(mc, sole_medium(mc, resolved_med, exid))
|
|
476
|
+
try:
|
|
477
|
+
sols = GapFiller(mc, universal=uni, lower_bound=0.05,
|
|
478
|
+
exchange_reactions=False, demand_reactions=False).fill(max_iter)
|
|
479
|
+
except Exception as e:
|
|
480
|
+
_note(f"[l3a] GapFiller failed: {e}")
|
|
481
|
+
sols = []
|
|
482
|
+
picked = [r for r in (sols[0] if sols else []) if r.id.startswith("l3a_")]
|
|
483
|
+
l3a["verdict"] = "no solution" if not picked else "relaxed bounds"
|
|
484
|
+
l3a["picked"] = [r.id[4:] for r in picked]
|
|
485
|
+
for pr in picked:
|
|
486
|
+
orig = pr.id[4:]
|
|
487
|
+
r0 = cur.reactions.get_by_id(orig)
|
|
488
|
+
old = r0.bounds
|
|
489
|
+
r0.bounds = (-1000.0, 1000.0)
|
|
490
|
+
r0.notes["bound_relaxed_by"] = SOURCE_TAG
|
|
491
|
+
r0.notes["evidence"] = "EVIDENCE_math"
|
|
492
|
+
relaxed_all.append({"rxn": orig, "old_bounds": list(old), "substrate": sub,
|
|
493
|
+
"evidence": "EVIDENCE_math"})
|
|
494
|
+
growth_a = _growth_sole(cur, resolved_med, exid)
|
|
495
|
+
l3a["growth_sole_after"] = round(growth_a, 6)
|
|
496
|
+
l3a["units"] = "mmol/gDW/h"
|
|
497
|
+
l3a["point_value_note"] = "单点 FBA 值,非解空间硬结论;条件对比请用 gem_fluxscan 区间分离判定"
|
|
498
|
+
entry["l3a"] = l3a
|
|
499
|
+
|
|
500
|
+
# ---- L3b: 白名单/BiGG 反应式(MILP 从候选池取最小集)----
|
|
501
|
+
l3b = {"attempted": len(pool_model.reactions) > 0, "pool": port_report}
|
|
502
|
+
added_here = []
|
|
503
|
+
if len(pool_model.reactions) > 0 and growth_a < 1e-6:
|
|
504
|
+
mb = cur.copy()
|
|
505
|
+
_set_medium(mb, sole_medium(mb, resolved_med, exid))
|
|
506
|
+
try:
|
|
507
|
+
sols = GapFiller(mb, universal=pool_model, lower_bound=0.05,
|
|
508
|
+
exchange_reactions=False, demand_reactions=False).fill(max_iter)
|
|
509
|
+
except Exception as e:
|
|
510
|
+
_note(f"[l3b] GapFiller failed: {e}")
|
|
511
|
+
sols = []
|
|
512
|
+
picked = [p for p in (sols[0] if sols else []) if p.id.endswith(NEW_RXN_SUFFIX)]
|
|
513
|
+
gate = budget_gate(cur, planned=len(picked), confirm_budget=confirm_budget)
|
|
514
|
+
if gate:
|
|
515
|
+
entry["l3b"] = {**l3b, **gate}
|
|
516
|
+
results.append(entry)
|
|
517
|
+
break
|
|
518
|
+
for pr in picked:
|
|
519
|
+
src = pool_model.reactions.get_by_id(pr.id)
|
|
520
|
+
if pr.id in seq_backed:
|
|
521
|
+
ev = "EVIDENCE_sequence"
|
|
522
|
+
elif pr.id in seq_hinted:
|
|
523
|
+
ev = "EVIDENCE_math" # tbl 间接桥:有序列线索但非直接对应,保守降级
|
|
524
|
+
else:
|
|
525
|
+
ev = "EVIDENCE_math"
|
|
526
|
+
rid = pr.id if pr.id not in cur.reactions else pr.id + f"_{len(applied_all)}"
|
|
527
|
+
r = cobra.Reaction(rid, name=(pr.name or pr.id),
|
|
528
|
+
lower_bound=pr.lower_bound, upper_bound=pr.upper_bound)
|
|
529
|
+
mm = {}
|
|
530
|
+
for x, c in src.metabolites.items():
|
|
531
|
+
if x.id in cur.metabolites:
|
|
532
|
+
mm[cur.metabolites.get_by_id(x.id)] = c
|
|
533
|
+
else: # 新代谢物随反应引入(共享缓存对象,防同 id 异对象)
|
|
534
|
+
nm = new_met_cache.get(x.id)
|
|
535
|
+
if nm is None or nm.id != x.id:
|
|
536
|
+
nm = next((v for v in new_met_cache.values() if v.id == x.id), None)
|
|
537
|
+
if nm is None:
|
|
538
|
+
nm = cobra.Metabolite(x.id, name=x.name, compartment=x.compartment,
|
|
539
|
+
formula=x.formula, charge=x.charge)
|
|
540
|
+
nm.notes["source"] = SOURCE_TAG
|
|
541
|
+
new_met_cache[x.id] = nm
|
|
542
|
+
mm[nm] = c
|
|
543
|
+
r.add_metabolites(mm)
|
|
544
|
+
cur.add_reactions([r])
|
|
545
|
+
wlid = uid_to_wlid.get(pr.id[:-len(NEW_RXN_SUFFIX)])
|
|
546
|
+
r.notes["source"] = SOURCE_TAG
|
|
547
|
+
r.notes["evidence"] = ev
|
|
548
|
+
r.notes["reason"] = (f"L3b: ported from iML1515 {pr.id[:-len(NEW_RXN_SUFFIX)]}"
|
|
549
|
+
f"{' via whitelist ' + wlid if wlid else ''}"
|
|
550
|
+
f"{' (tbl-indirect bridge, sequence hint only)' if pr.id in seq_hinted and wlid else ''}"
|
|
551
|
+
f"; substrate {sub}")
|
|
552
|
+
added_here.append({"rxn": rid, "evidence": ev, "substrate": sub,
|
|
553
|
+
"sequence_backed": ev == "EVIDENCE_sequence",
|
|
554
|
+
"sequence_hint": pr.id in seq_hinted and wlid is not None})
|
|
555
|
+
applied_all.append(added_here[-1])
|
|
556
|
+
_note(f"[l3b] {sub}: picked {len(picked)}, added {len(added_here)}")
|
|
557
|
+
growth_b = _growth_sole(cur, resolved_med, exid)
|
|
558
|
+
l3b["growth_sole_after"] = round(growth_b, 6)
|
|
559
|
+
l3b["units"] = "mmol/gDW/h"
|
|
560
|
+
l3b["point_value_note"] = "单点 FBA 值,非解空间硬结论;条件对比请用 gem_fluxscan 区间分离判定"
|
|
561
|
+
l3b["added"] = added_here
|
|
562
|
+
entry["l3b"] = l3b
|
|
563
|
+
entry["growth_sole_after"] = round(max(growth_a, growth_b), 6)
|
|
564
|
+
entry["units"] = "mmol/gDW/h"
|
|
565
|
+
entry["point_value_note"] = "单点 FBA 值,非解空间硬结论;条件对比请用 gem_fluxscan 区间分离判定"
|
|
566
|
+
entry["verdict"] = "fixed" if growth_b > 1e-6 else "not_fixable"
|
|
567
|
+
if entry["verdict"] == "not_fixable":
|
|
568
|
+
entry["unfixable_evidence"] = [
|
|
569
|
+
f"sole 语义生长 before={growth_before:.6f}(有交换+转运但 FBA 不长,即 L3)",
|
|
570
|
+
f"L3a: {entry['l3a']['verdict']}(lp_relax_growth={entry['l3a'].get('lp_relax_growth')})",
|
|
571
|
+
(f"L3b: 候选池 {len(pool_model.reactions)} 条(白名单桥接 {port_report['bridged']}"
|
|
572
|
+
f"→移植 {port_report['ported']},PTS 排除 {port_report['excluded_pts']});"
|
|
573
|
+
f"MILP 未选出能恢复生长的集合"),
|
|
574
|
+
f"白名单缓存: {wl_cache or 'n/a'}({len(rxn_hits or {})} 命中;桥规则=EC/名字规约)",
|
|
575
|
+
]
|
|
576
|
+
results.append(entry)
|
|
577
|
+
|
|
578
|
+
resp = {"ok": True, "l3_input": results, "medium_preset": preset_name,
|
|
579
|
+
"medium_unresolved": unresolved,
|
|
580
|
+
"whitelist": {"cache": wl_cache, "source": (wl_src or {}).get("source"),
|
|
581
|
+
"n_hits": len(rxn_hits or {})},
|
|
582
|
+
"budget": {"prior_added": prior_added(m), "budget": budget_for(m),
|
|
583
|
+
"added_this_run": len(applied_all) + len(relaxed_all)},
|
|
584
|
+
"applied": applied_all, "bound_relaxed": relaxed_all,
|
|
585
|
+
"rolled_back": False, "out": None, "elapsed_s": None}
|
|
586
|
+
|
|
587
|
+
# ---- 落盘 + G1-G6 重验(G6 能量循环哨兵,失败回滚)----
|
|
588
|
+
if applied_all or relaxed_all:
|
|
589
|
+
if not out:
|
|
590
|
+
out = model_path[:-4] + "_l3.xml" if model_path.endswith(".xml") else model_path + "_l3.xml"
|
|
591
|
+
silent_write_sbml(cur, out)
|
|
592
|
+
rep = validate_model(out, medium=resolved_med)
|
|
593
|
+
g6 = rep.get("g6") or {}
|
|
594
|
+
resp["validate"] = {k: (rep.get(k) or {}).get("status") for k in ("g1", "g2", "g3", "g4", "g5", "g6")}
|
|
595
|
+
resp["g6_after"] = g6
|
|
596
|
+
if g6.get("status") != "PASS":
|
|
597
|
+
_rollback(cur, applied_all, relaxed_all)
|
|
598
|
+
silent_write_sbml(cur, out)
|
|
599
|
+
rep2 = validate_model(out, medium=resolved_med)
|
|
600
|
+
resp["rolled_back"] = True
|
|
601
|
+
resp["rollback_reason"] = f"G6 {g6.get('status')} atp_leak={g6.get('atp_leak_flux')}"
|
|
602
|
+
resp["validate"] = {k: (rep2.get(k) or {}).get("status") for k in ("g1", "g2", "g3", "g4", "g5", "g6")}
|
|
603
|
+
resp["g6_after"] = rep2.get("g6")
|
|
604
|
+
resp["applied"] = []
|
|
605
|
+
resp["bound_relaxed"] = []
|
|
606
|
+
resp["out"] = out
|
|
607
|
+
# 模型卡 lineage 追加(源模型旁有 card 才传播+追加;无卡不凭空造卡)
|
|
608
|
+
try:
|
|
609
|
+
from model_card import append_operation, load_card, propagate_card
|
|
610
|
+
propagate_card(model_path, out)
|
|
611
|
+
if load_card(out) is not None:
|
|
612
|
+
card = append_operation(out, "l3_fix", reactions_added=len(resp["applied"]),
|
|
613
|
+
detail={"bound_relaxed": len(resp["bound_relaxed"]),
|
|
614
|
+
"rolled_back": resp["rolled_back"],
|
|
615
|
+
"substrates": [i.get("substrate") for i in results],
|
|
616
|
+
"evidence": resp.get("evidence_summary")})
|
|
617
|
+
resp["card_version"] = (card or {}).get("model_lineage", {}).get("version")
|
|
618
|
+
except Exception:
|
|
619
|
+
pass
|
|
620
|
+
else:
|
|
621
|
+
resp["note"] = "no L3 fix applied(均为不可补或无候选;证据见 l3_input)"
|
|
622
|
+
|
|
623
|
+
evc = {"EVIDENCE_sequence": 0, "EVIDENCE_math": 0, "EVIDENCE_rule": 0}
|
|
624
|
+
for a in resp["applied"]:
|
|
625
|
+
evc[a["evidence"]] = evc.get(a["evidence"], 0) + 1
|
|
626
|
+
for rb in resp["bound_relaxed"]:
|
|
627
|
+
evc[rb["evidence"]] = evc.get(rb["evidence"], 0) + 1
|
|
628
|
+
resp["evidence_summary"] = evc
|
|
629
|
+
resp["elapsed_s"] = round(time.time() - t0, 1)
|
|
630
|
+
return resp
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
if __name__ == "__main__":
|
|
634
|
+
args = json.loads(open(sys.argv[1], encoding="utf-8").read()) if len(sys.argv) > 1 else {}
|
|
635
|
+
print(json.dumps(l3_fix(args.get("model"), args.get("medium"), args.get("substrates"),
|
|
636
|
+
args.get("out"), allow_math=args.get("allow_math", False),
|
|
637
|
+
confirm_budget=args.get("confirm_budget", False),
|
|
638
|
+
whitelist=args.get("whitelist"), faa=args.get("faa"),
|
|
639
|
+
species=args.get("species"), max_iter=args.get("max_iter", 1),
|
|
640
|
+
universal_path=args.get("universal_path")),
|
|
641
|
+
ensure_ascii=False, indent=2))
|