@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,208 @@
1
+ # annotate.py — 路线 P0 注释步骤(纯 Windows)
2
+ # 策略(GLM 二轮 Q1 采纳 + 验证协议):官方注释优先 + pyrodigal 兜底
3
+ # 1) 同目录 *_protein.faa 存在(NCBI dataset 常见)→ 直接用
4
+ # 2) 同目录 *.gff(含 CDS)→ 解析坐标从 fna 提取 + 翻译(transl_table 11)
5
+ # 3) 仅 .fna → pyrodigal(多序列模式;总长 <100kb 时 meta 模式)
6
+ # 验证:predict 蛋白集合 vs 官方蛋白的完全一致率应 85-92%(C58 验证见 logs)。
7
+ import os
8
+ import sys
9
+ import csv
10
+ import glob
11
+ import pyrodigal
12
+
13
+ # 标准遗传密码子表(transl_table 11,杆菌默认;零依赖实现)
14
+ CODON_TABLE = {
15
+ "TTT": "F", "TTC": "F", "TTA": "L", "TTG": "L", "TCT": "S", "TCC": "S", "TCA": "S",
16
+ "TCG": "S", "TAT": "Y", "TAC": "Y", "TAA": "*", "TAG": "*", "TGT": "C", "TGC": "C",
17
+ "TGA": "*", "TGG": "W", "CTT": "L", "CTC": "L", "CTA": "L", "CTG": "L", "CCT": "P",
18
+ "CCC": "P", "CCA": "P", "CCG": "P", "CAT": "H", "CAC": "H", "CAA": "Q", "CAG": "Q",
19
+ "CGT": "R", "CGC": "R", "CGA": "R", "CGG": "R", "ATT": "I", "ATC": "I", "ATA": "I",
20
+ "ATG": "M", "ACT": "T", "ACC": "T", "ACA": "T", "ACG": "T", "AAT": "N", "AAC": "N",
21
+ "AAA": "K", "AAG": "K", "AGT": "S", "AGC": "S", "AGA": "R", "AGG": "R",
22
+ "GTT": "V", "GTC": "V", "GTA": "V", "GTG": "V", "GCT": "A", "GCC": "A", "GCA": "A",
23
+ "GCG": "A", "GAT": "D", "GAC": "D", "GAA": "E", "GAG": "E", "GGT": "G", "GGC": "G",
24
+ "GGA": "G", "GGG": "G",
25
+ }
26
+
27
+
28
+ def translate_nt(dna):
29
+ """DNA -> 蛋白(标准表 11;到 stop 截止)。"""
30
+ s = dna.upper().replace("U", "T")
31
+ out = []
32
+ for i in range(0, len(s) - 2, 3):
33
+ aa = CODON_TABLE.get(s[i:i + 3], "X")
34
+ if aa == "*":
35
+ break
36
+ out.append(aa)
37
+ return "".join(out)
38
+
39
+
40
+ def read_fasta(path):
41
+ recs = []
42
+ cur, cur_id = [], None
43
+ with open(path, encoding="utf-8", errors="ignore") as f:
44
+ for line in f:
45
+ line = line.strip()
46
+ if not line:
47
+ continue
48
+ if line.startswith(">"):
49
+ if cur_id is not None:
50
+ recs.append((cur_id, "".join(cur)))
51
+ cur_id = line[1:].split()[0]
52
+ cur = []
53
+ else:
54
+ cur.append(line)
55
+ if cur_id is not None:
56
+ recs.append((cur_id, "".join(cur)))
57
+ return recs
58
+
59
+
60
+ def _translate_cds_file(cds_fna, out_faa):
61
+ """官方 CDS 核酸 -> 蛋白(transl_table 11 直译)。"""
62
+ n = 0
63
+ with open(out_faa, "w", encoding="utf-8") as fo:
64
+ for rid, seq in read_fasta(cds_fna):
65
+ prot = translate_nt(seq)
66
+ if prot:
67
+ fo.write(f">{rid}\n{prot}\n")
68
+ n += 1
69
+ return out_faa, n
70
+
71
+
72
+ def nucleotide_to_protein(fna_path, out_faa=None, prefer_existing=True):
73
+ """.fna -> .faa(官方优先 + pyrodigal 兜底)。返回 (faa_path, source, stats)。"""
74
+ d = os.path.dirname(fna_path) or "."
75
+ base = os.path.splitext(os.path.basename(fna_path))[0]
76
+ if out_faa is None:
77
+ out_faa = os.path.join(d, base + ".gem_annot.faa")
78
+ # P2-8 修复(2026-08-31 LBA9402 会话首调真实事故):输出目录不存在直接 FileNotFoundError
79
+ out_dir = os.path.dirname(os.path.abspath(out_faa)) or "."
80
+ os.makedirs(out_dir, exist_ok=True)
81
+
82
+ # 1) 优先已有的官方蛋白
83
+ if prefer_existing:
84
+ cands = [f for f in glob.glob(os.path.join(d, "*_protein.faa"))]
85
+ cands += [f for f in glob.glob(os.path.join(d, base + ".faa"))]
86
+ if cands:
87
+ best = cands[0]
88
+ n = sum(1 for l in open(best, encoding="utf-8", errors="ignore") if l.startswith(">"))
89
+ if n > 50:
90
+ return best, "official_protein", {"seqs": n, "gene_table": None,
91
+ "note": f"现有蛋白 {os.path.basename(best)}"}
92
+
93
+ # 2) 官方 CDS(cds_from_genomic.fna)直译蛋白
94
+ cds = os.path.join(d, "cds_from_genomic.fna")
95
+ if os.path.exists(cds):
96
+ faa, n = _translate_cds_file(cds, out_faa)
97
+ if n > 50:
98
+ return faa, "cds_translate", {"seqs": n, "gene_table": None}
99
+
100
+ # 3) GFF 官方注释解析翻译
101
+ gffs = glob.glob(os.path.join(d, "*.gff")) + glob.glob(os.path.join(d, "*.gff3"))
102
+ if gffs and os.path.exists(fna_path):
103
+ faa, n, gt = _translate_from_gff(fna_path, gffs[0], out_faa)
104
+ if faa and n > 50:
105
+ return faa, "gff_translate", {"seqs": n, "gene_table": gt}
106
+
107
+ # 4) pyrodigal 兜底
108
+ n = _pyrodigal_predict(fna_path, out_faa)
109
+ return out_faa, "pyrodigal", {"seqs": n, "gene_table": None}
110
+
111
+
112
+ def _pyrodigal_predict(fna_path, out_faa):
113
+ """pyrodigal 多序列预测(总长 <100kb 强制 meta 模式)。"""
114
+ model = pyrodigal.GeneFinder(meta=True)
115
+ seqs = []
116
+ with open(fna_path, encoding="utf-8", errors="ignore") as f:
117
+ cur, cur_id = [], None
118
+ for line in f:
119
+ line = line.strip()
120
+ if not line:
121
+ continue
122
+ if line.startswith(">"):
123
+ if cur_id is not None:
124
+ seqs.append((cur_id, "".join(cur).upper()))
125
+ cur_id = line[1:].split()[0]
126
+ cur = []
127
+ else:
128
+ cur.append(line)
129
+ if cur_id is not None:
130
+ seqs.append((cur_id, "".join(cur).upper()))
131
+ total = sum(len(s) for _, s in seqs)
132
+ n_out = 0
133
+ with open(out_faa, "w", encoding="utf-8") as fo:
134
+ for rec_id, seq in seqs:
135
+ genes = model.find_genes(seq)
136
+ for g in genes:
137
+ fo.write(f">{rec_id}_{g.begin}_{g.end}_{'+' if g.strand == 1 else '-'}\n")
138
+ fo.write(g.translate() + "\n")
139
+ n_out += 1
140
+ return n_out
141
+
142
+
143
+ def _parse_gff_attrs(col9):
144
+ """GFF 第 9 列 attributes -> dict(剥引号)。支持 key=value 与 key value 两种分隔。"""
145
+ d = {}
146
+ if not col9:
147
+ return d
148
+ for kv in col9.rstrip("\n").split(";"):
149
+ if not kv.strip():
150
+ continue
151
+ if "=" in kv:
152
+ k, v = kv.split("=", 1)
153
+ elif " " in kv:
154
+ k, v = kv.split(" ", 1)
155
+ else:
156
+ continue
157
+ k = k.strip()
158
+ v = v.strip().strip('"').strip("'")
159
+ if k:
160
+ d[k] = v
161
+ return d
162
+
163
+
164
+ def _translate_from_gff(fna_path, gff_path, out_faa):
165
+ """从 GFF 提取 CDS 坐标并在 fna 上翻译(零依赖:内嵌密码子表)。
166
+ 同时产出基因注释表 <out_faa>.gene_table.tsv(P1-4:坐标ID→locus_tag/gene/product,
167
+ 供 gem_essentiality 解读必需基因功能)。返回 (out_faa, n, gene_table_path)。"""
168
+ _RC = str.maketrans("ACGTNacgtn", "TGCANtgcan")
169
+ genome = {rid: seq for rid, seq in read_fasta(fna_path)}
170
+ gene_table_path = os.path.splitext(out_faa)[0] + ".gene_table.tsv"
171
+ n = 0
172
+ rows = []
173
+ with open(out_faa, "w", encoding="utf-8") as fo:
174
+ for line in open(gff_path, encoding="utf-8", errors="ignore"):
175
+ if line.startswith("#"):
176
+ continue
177
+ p = line.rstrip("\n").split("\t")
178
+ if len(p) < 9 or p[2] != "CDS":
179
+ continue
180
+ seqid, start, end, strand = p[0], int(p[3]), int(p[4]), p[6]
181
+ seq = genome.get(seqid)
182
+ if not seq:
183
+ continue
184
+ cds = seq[start - 1:end]
185
+ if strand == "-":
186
+ cds = cds.translate(_RC)[::-1]
187
+ prot = translate_nt(cds)
188
+ gid = f"{seqid}_{start}_{end}_{strand}"
189
+ fo.write(f">{gid}\n{prot}\n")
190
+ n += 1
191
+ attrs = _parse_gff_attrs(p[8])
192
+ rows.append([gid, seqid, start, end, strand,
193
+ attrs.get("locus_tag") or "",
194
+ attrs.get("gene") or attrs.get("Name") or attrs.get("gene_name") or "",
195
+ attrs.get("product") or ""])
196
+ with open(gene_table_path, "w", newline="", encoding="utf-8") as gt:
197
+ w = csv.writer(gt, delimiter="\t")
198
+ w.writerow(["gene_id", "seqid", "start", "end", "strand",
199
+ "locus_tag", "gene_name", "product"])
200
+ w.writerows(rows)
201
+ return out_faa, n, gene_table_path
202
+
203
+
204
+ if __name__ == "__main__":
205
+ import json
206
+ args = json.loads(open(sys.argv[1], encoding="utf-8").read()) if len(sys.argv) > 1 else {}
207
+ faa, src, stats = nucleotide_to_protein(args["fna"], args.get("out"))
208
+ print(json.dumps({"faa": faa, "source": src, "stats": stats}, ensure_ascii=False, indent=2))