alnkit 0.2.0__py3-none-any.whl
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.
- alnkit/__init__.py +11 -0
- alnkit/__main__.py +10 -0
- alnkit/_docs.py +53 -0
- alnkit/cli/__init__.py +53 -0
- alnkit/cli/__main__.py +10 -0
- alnkit/cli/_common.py +25 -0
- alnkit/cli/align.py +52 -0
- alnkit/cli/doctor.py +81 -0
- alnkit/cli/long.py +346 -0
- alnkit/cli/plot.py +293 -0
- alnkit/cli/ref.py +50 -0
- alnkit/cli/seg.py +131 -0
- alnkit/cli/selftest.py +308 -0
- alnkit/cli/short.py +245 -0
- alnkit/core/__init__.py +9 -0
- alnkit/core/bam_segments.py +183 -0
- alnkit/core/coords.py +129 -0
- alnkit/core/errors.py +25 -0
- alnkit/core/external.py +63 -0
- alnkit/core/fasta.py +83 -0
- alnkit/core/logging.py +25 -0
- alnkit/core/paf.py +52 -0
- alnkit/core/segments.py +253 -0
- alnkit/core/tables.py +340 -0
- alnkit/longread/__init__.py +5 -0
- alnkit/longread/assemble.py +453 -0
- alnkit/longread/breakpoints.py +910 -0
- alnkit/longread/classify.py +958 -0
- alnkit/longread/graph.py +174 -0
- alnkit/longread/locate.py +904 -0
- alnkit/plot/__init__.py +5 -0
- alnkit/plot/ribbon.py +1306 -0
- alnkit/plot/tracks.py +1035 -0
- alnkit/prepare.py +256 -0
- alnkit/py.typed +0 -0
- alnkit/shortread/__init__.py +20 -0
- alnkit/shortread/bam.py +527 -0
- alnkit/shortread/evidence.py +214 -0
- alnkit/shortread/interference.py +44 -0
- alnkit/shortread/merge.py +126 -0
- alnkit/shortread/model.py +301 -0
- alnkit/shortread/origin.py +507 -0
- alnkit/shortread/report.py +316 -0
- alnkit/shortread/scan.py +788 -0
- alnkit-0.2.0.dist-info/METADATA +163 -0
- alnkit-0.2.0.dist-info/RECORD +49 -0
- alnkit-0.2.0.dist-info/WHEEL +4 -0
- alnkit-0.2.0.dist-info/entry_points.txt +2 -0
- alnkit-0.2.0.dist-info/licenses/LICENSE +674 -0
|
@@ -0,0 +1,958 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# ---------------------------------------------------------------------------
|
|
3
|
+
# A3:把 reads 分成「载体区域 / 接头区域」,并把接头事件聚成位点与区域。
|
|
4
|
+
|
|
5
|
+
# 来源:upstream/scripts/03_classify_reads.py(700 行)的忠实移植。逻辑未改;
|
|
6
|
+
# 相对原脚本只有三处包装:参数解析搬到 alnkit.cli.long、库层收 ClassifySettings、
|
|
7
|
+
# main() 改为 run()。输出表 / 子集 FASTQ 与旧脚本逐字节一致。
|
|
8
|
+
#
|
|
9
|
+
# 输入:
|
|
10
|
+
# 02_align_merged/reads_to_merged.sorted.bam reads -> 合并参考(参考 + 外源)
|
|
11
|
+
# 01_merge_ref/merged_reference.fa.fai 取序列长度
|
|
12
|
+
# data/rawdata/input.fastq 原始 reads(输出子集用)
|
|
13
|
+
# 输出:
|
|
14
|
+
# 03_classify_reads/alignment_segments.tsv 只列有外源证据的 read 的非 secondary 比对段
|
|
15
|
+
# 03_classify_reads/read_classification.tsv 只列有外源证据的 read(junction / vector_region)
|
|
16
|
+
# 03_classify_reads/classification_summary.tsv 总数/目标数/非目标数(不输出非目标逐条记录)
|
|
17
|
+
# 以下旧 200 kb 中间表仅脚本内部兼容输出,shell 随后删除,不参与方案 A:
|
|
18
|
+
# 03_classify_reads/junction_events.tsv / junction_sites.tsv / locus_regions.tsv
|
|
19
|
+
# 03_classify_reads/related_reads.fastq 所有相关 reads(载体区域 + 接头)
|
|
20
|
+
# 03_classify_reads/vector_region_reads.fastq 只比对外源、连不上基因组的 reads
|
|
21
|
+
# 03_classify_reads/junction_reads.fastq 同时比对外源与基因组的 reads
|
|
22
|
+
#
|
|
23
|
+
# 关键口径:
|
|
24
|
+
# * 外源证据:外源比对段 >= --min-exog-block-bp(默认 100 bp)且 MAPQ >= --min-mapq(20)。
|
|
25
|
+
# * 基因组证据:参考基因组比对段 MAPQ 达标;若同时满足
|
|
26
|
+
# “落在 contig 两端 --contig-edge-bp(100 bp)内”且“长度 <= --short-genome-block-bp(500 bp)”
|
|
27
|
+
# 则判为可疑错配,丢弃。
|
|
28
|
+
# * 接头事件:基因组段与外源段在 read 轴上相邻(间隔 <= --junction-max-gap-bp,默认 10 kb;
|
|
29
|
+
# 实测接头两侧常有几 kb 未比对上的间隔,所以这里不要求严格紧邻)。
|
|
30
|
+
# 边界和 side 都按**参考基因组方向**给出(已考虑比对链):
|
|
31
|
+
# genome_left = 基因组在边界左侧,插入从边界开始;
|
|
32
|
+
# genome_right = 基因组在边界右侧,插入在边界结束。
|
|
33
|
+
# (07 画丝带图时取边界旁 50 bp 作锚点。)
|
|
34
|
+
# * 位点:同染色体、同 side 的边界差 <= --site-gap-bp(20 bp)聚成一个位点。
|
|
35
|
+
# * 区域/类别:同染色体上位点边界相距 <= --region-gap-bp(200 kb)的位点归为一个区域;
|
|
36
|
+
# 区域是后续“按接头位点分类”的类别单位(一个插入可能有多个断点/两侧边界)。
|
|
37
|
+
# * 内存:先按外源序列找出目标 read(通常远少于全部 reads),再只加载这些 read 的
|
|
38
|
+
# 非 secondary 比对段;genome_only/other 不进入本表,也不加载它们的比对段。
|
|
39
|
+
#
|
|
40
|
+
# 输入示例:
|
|
41
|
+
# --bam 02_align_merged/reads_to_merged.sorted.bam
|
|
42
|
+
# --reads data/rawdata/input.fastq --merged-fai 01_merge_ref/merged_reference.fa.fai
|
|
43
|
+
# --exog insertA --exog insertB --outdir 03_classify_reads
|
|
44
|
+
# 输出示例(read_classification.tsv):
|
|
45
|
+
# read_name read_length read_category n_exog_segments exog_names ...
|
|
46
|
+
# sampleA-run/ccs 21569 junction 1 insertB ...
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
from __future__ import annotations
|
|
49
|
+
|
|
50
|
+
import collections
|
|
51
|
+
import os
|
|
52
|
+
from dataclasses import dataclass
|
|
53
|
+
|
|
54
|
+
import pysam
|
|
55
|
+
|
|
56
|
+
from ..core.bam_segments import load_alignment_segments as _load_alignment_segments
|
|
57
|
+
from ..core.bam_segments import load_fai_lengths
|
|
58
|
+
from ..core.coords import merge_intervals
|
|
59
|
+
from ..core.errors import UsageError
|
|
60
|
+
from ..core.tables import write_legacy_tsv as write_tsv
|
|
61
|
+
|
|
62
|
+
# 旧脚本的比对段是 dict;core 用 Segment 模型。这里做一层边界适配,
|
|
63
|
+
# 字段名与空值口径(chrom_length 缺失记 "-")保持与 alignment_table 完全一致。
|
|
64
|
+
_SEGMENT_FIELDS = (
|
|
65
|
+
("chrom", "chrom"),
|
|
66
|
+
("read_name", "read_name"),
|
|
67
|
+
("ref_start", "ref_start_0based"),
|
|
68
|
+
("ref_end", "ref_end_0based"),
|
|
69
|
+
("read_start", "read_start_0based"),
|
|
70
|
+
("read_end", "read_end_0based"),
|
|
71
|
+
("read_length", "read_length"),
|
|
72
|
+
("strand", "strand"),
|
|
73
|
+
("mapq", "mapq"),
|
|
74
|
+
("nm", "nm"),
|
|
75
|
+
("match_columns", "match_columns"),
|
|
76
|
+
("inserted_bases", "inserted_bases"),
|
|
77
|
+
("deleted_bases", "deleted_bases"),
|
|
78
|
+
("ref_aln_len", "ref_aln_len"),
|
|
79
|
+
("read_aln_len", "read_aln_len"),
|
|
80
|
+
("cigar", "cigar"),
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _segment_to_legacy_dict(segment):
|
|
85
|
+
record = {legacy: getattr(segment, field) for legacy, field in _SEGMENT_FIELDS}
|
|
86
|
+
record["chrom_length"] = "-" if segment.chrom_length is None else segment.chrom_length
|
|
87
|
+
record["is_secondary"] = int(segment.is_secondary)
|
|
88
|
+
record["is_supplementary"] = int(segment.is_supplementary)
|
|
89
|
+
return record
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def load_alignment_segments(
|
|
93
|
+
bam_path, chrom_lengths=None, selected_read_names=None, skip_secondary=False
|
|
94
|
+
):
|
|
95
|
+
"""与旧 ``alignment_table.load_alignment_segments`` 等价的 dict 版加载。"""
|
|
96
|
+
return [
|
|
97
|
+
_segment_to_legacy_dict(segment)
|
|
98
|
+
for segment in _load_alignment_segments(
|
|
99
|
+
bam_path,
|
|
100
|
+
chrom_lengths=chrom_lengths,
|
|
101
|
+
selected_read_names=selected_read_names,
|
|
102
|
+
skip_secondary=skip_secondary,
|
|
103
|
+
)
|
|
104
|
+
]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
### 读取比对段 ###
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def find_target_read_names(bam_path, exog_names, min_mapq, min_exog_block_bp):
|
|
111
|
+
"""先只找出“有合格外源比对”的 read 名(不把全部比对段读进内存)。
|
|
112
|
+
|
|
113
|
+
输入示例: find_target_read_names("reads_to_merged.sorted.bam", ("insertA", "insertB"), 20, 100)
|
|
114
|
+
输出示例: {"m.../ccs", "m.../ccs2", ...}
|
|
115
|
+
说明:
|
|
116
|
+
* 优先用 BAM 索引按外源序列 `fetch`,只扫描外源上的记录(很小);
|
|
117
|
+
* 外源名不在 BAM 里、或 BAM 没有索引时,退化成一次全量扫描;
|
|
118
|
+
* 过滤条件与 classify_segment 的外源证据一致:非 secondary、MAPQ 达标、
|
|
119
|
+
外源比对跨度 >= min_exog_block_bp。
|
|
120
|
+
"""
|
|
121
|
+
if not os.path.exists(bam_path):
|
|
122
|
+
raise UsageError(f"找不到 BAM:{bam_path}")
|
|
123
|
+
|
|
124
|
+
target_read_names = set()
|
|
125
|
+
|
|
126
|
+
def keep_exog_record(read):
|
|
127
|
+
if read.is_unmapped or read.is_secondary:
|
|
128
|
+
return False
|
|
129
|
+
if read.reference_name not in exog_names:
|
|
130
|
+
return False
|
|
131
|
+
if read.mapping_quality < min_mapq:
|
|
132
|
+
return False
|
|
133
|
+
if (read.reference_end - read.reference_start) < min_exog_block_bp:
|
|
134
|
+
return False
|
|
135
|
+
return True
|
|
136
|
+
|
|
137
|
+
try:
|
|
138
|
+
with pysam.AlignmentFile(bam_path, "rb") as bam_file:
|
|
139
|
+
available_references = set(bam_file.references)
|
|
140
|
+
missing_exog = [name for name in exog_names if name not in available_references]
|
|
141
|
+
if missing_exog:
|
|
142
|
+
raise UsageError(
|
|
143
|
+
"--exog 里这些名字不在 BAM header 中:"
|
|
144
|
+
+ ", ".join(missing_exog)
|
|
145
|
+
+ ";可用序列名示例:"
|
|
146
|
+
+ ", ".join(sorted(available_references)[:5])
|
|
147
|
+
)
|
|
148
|
+
for exog_name in exog_names:
|
|
149
|
+
if exog_name not in available_references:
|
|
150
|
+
continue
|
|
151
|
+
for read in bam_file.fetch(exog_name):
|
|
152
|
+
if keep_exog_record(read):
|
|
153
|
+
target_read_names.add(read.query_name)
|
|
154
|
+
except (ValueError, OSError):
|
|
155
|
+
# 没有 .bai 或 fetch 失败:退化成一次全量扫描,只保留目标 read 名(仍然不存比对段)
|
|
156
|
+
with pysam.AlignmentFile(bam_path, "rb") as bam_file:
|
|
157
|
+
for read in bam_file:
|
|
158
|
+
if keep_exog_record(read):
|
|
159
|
+
target_read_names.add(read.query_name)
|
|
160
|
+
return target_read_names
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def load_segments_by_read(bam_path, chrom_lengths, selected_read_names):
|
|
164
|
+
"""读取 BAM,只保留指定 read 的非 secondary 比对段。
|
|
165
|
+
|
|
166
|
+
输入示例: load_segments_by_read("reads_to_merged.sorted.bam", {"insertA": 1353, ...},
|
|
167
|
+
{"m.../ccs", "m.../ccs2"})
|
|
168
|
+
输出示例: {"m.../ccs": [{"chrom": "insertA", "ref_start": 0, "ref_end": 1353, ...}, ...]}
|
|
169
|
+
说明:supplementary 保留(split alignment 是接头证据);secondary 在解析 CIGAR 前就丢弃;
|
|
170
|
+
selected_read_names 由 find_target_read_names() 得到,避免加载非目标 reads。
|
|
171
|
+
"""
|
|
172
|
+
segments_by_read = collections.defaultdict(list)
|
|
173
|
+
for segment in load_alignment_segments(
|
|
174
|
+
bam_path,
|
|
175
|
+
chrom_lengths=chrom_lengths,
|
|
176
|
+
selected_read_names=selected_read_names,
|
|
177
|
+
skip_secondary=True,
|
|
178
|
+
):
|
|
179
|
+
segments_by_read[segment["read_name"]].append(segment)
|
|
180
|
+
return segments_by_read
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def classify_segment(segment, exog_names, settings):
|
|
184
|
+
"""判断一个比对段是“外源证据 / 基因组证据 / 可疑错配”,返回 (role, kept, reason)。
|
|
185
|
+
|
|
186
|
+
输入示例: classify_segment({"chrom": "insertA", "ref_aln_len": 1353, "mapq": 60, ...},
|
|
187
|
+
("insertA", "insertB"), args)
|
|
188
|
+
输出示例: ("exog", True, "-")
|
|
189
|
+
说明:
|
|
190
|
+
* role = exog / genome / other;
|
|
191
|
+
* 外源段太短或 MAPQ 太低 -> kept=False;
|
|
192
|
+
* 基因组段落在 contig 两端 100 bp 内且 <= 500 bp -> kept=False(可疑错配)。
|
|
193
|
+
"""
|
|
194
|
+
chrom = segment["chrom"]
|
|
195
|
+
if chrom in exog_names:
|
|
196
|
+
if segment["ref_aln_len"] < settings.min_exog_block_bp:
|
|
197
|
+
return "exog", False, "exog_block_too_short"
|
|
198
|
+
if segment["mapq"] < settings.min_mapq:
|
|
199
|
+
return "exog", False, "low_mapq"
|
|
200
|
+
return "exog", True, "-"
|
|
201
|
+
|
|
202
|
+
if segment["mapq"] < settings.min_mapq:
|
|
203
|
+
return "genome", False, "low_mapq"
|
|
204
|
+
chrom_length = segment["chrom_length"]
|
|
205
|
+
if isinstance(chrom_length, int):
|
|
206
|
+
near_contig_edge = (
|
|
207
|
+
segment["ref_start"] < settings.contig_edge_bp
|
|
208
|
+
or chrom_length - segment["ref_end"] < settings.contig_edge_bp
|
|
209
|
+
)
|
|
210
|
+
if near_contig_edge and segment["ref_aln_len"] <= settings.short_genome_block_bp:
|
|
211
|
+
return "genome", False, "contig_edge_short_block"
|
|
212
|
+
return "genome", True, "-"
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
### 接头事件与位点聚类 ###
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def find_junction_events(read_name, exog_segments, genome_segments, max_gap_bp):
|
|
219
|
+
"""找出“基因组段紧邻外源段”的接头事件(每个基因组段左右两侧各最多一个)。
|
|
220
|
+
|
|
221
|
+
输入示例:
|
|
222
|
+
exog_segments = [{"chrom": "insertA", "read_start": 25000, "read_end": 26353, ...}]
|
|
223
|
+
genome_segments = [{"chrom": "chr1", "read_start": 0, "read_end": 25000,
|
|
224
|
+
"ref_start": 2002000, "ref_end": 2004500, ...}]
|
|
225
|
+
max_gap_bp = 100
|
|
226
|
+
输出示例:
|
|
227
|
+
[{"read_name": ..., "chrom": "chr1", "genome_side": "genome_left",
|
|
228
|
+
"boundary": 2004500, "vector_name": "insertA", "gap_bp": 0, ...}]
|
|
229
|
+
说明:
|
|
230
|
+
* 先看外源段在 read 轴的哪一侧,再结合基因组段的比对链,换算成参考基因组方向:
|
|
231
|
+
- 外源在 read 右侧、基因组链 + :genome_left,边界 = ref_end
|
|
232
|
+
- 外源在 read 右侧、基因组链 - :genome_right,边界 = ref_start
|
|
233
|
+
- 外源在 read 左侧、基因组链 + :genome_right,边界 = ref_start
|
|
234
|
+
- 外源在 read 左侧、基因组链 - :genome_left,边界 = ref_end
|
|
235
|
+
* boundary 是 0-based 边界;genome_left 时等于最后一个基因组碱基的 1-based 坐标。
|
|
236
|
+
"""
|
|
237
|
+
events = []
|
|
238
|
+
for genome_segment in genome_segments:
|
|
239
|
+
# 右侧最近的外源段(外源段起点 >= 基因组段终点)
|
|
240
|
+
right_vectors = [
|
|
241
|
+
segment
|
|
242
|
+
for segment in exog_segments
|
|
243
|
+
if segment["read_start"] >= genome_segment["read_end"]
|
|
244
|
+
]
|
|
245
|
+
if right_vectors:
|
|
246
|
+
vector_segment = min(right_vectors, key=lambda segment: segment["read_start"])
|
|
247
|
+
gap_bp = vector_segment["read_start"] - genome_segment["read_end"]
|
|
248
|
+
if gap_bp <= max_gap_bp:
|
|
249
|
+
# 外源在 read 右侧:链 + 时基因组在边界左侧,链 - 时在右侧
|
|
250
|
+
if genome_segment["strand"] == "+":
|
|
251
|
+
genome_side, boundary = "genome_left", genome_segment["ref_end"]
|
|
252
|
+
else:
|
|
253
|
+
genome_side, boundary = "genome_right", genome_segment["ref_start"]
|
|
254
|
+
events.append(
|
|
255
|
+
{
|
|
256
|
+
"read_name": read_name,
|
|
257
|
+
"chrom": genome_segment["chrom"],
|
|
258
|
+
"genome_side": genome_side,
|
|
259
|
+
"boundary": boundary,
|
|
260
|
+
"vector_name": vector_segment["chrom"],
|
|
261
|
+
"gap_bp": gap_bp,
|
|
262
|
+
"genome_read_start": genome_segment["read_start"],
|
|
263
|
+
"genome_read_end": genome_segment["read_end"],
|
|
264
|
+
"vector_read_start": vector_segment["read_start"],
|
|
265
|
+
"vector_read_end": vector_segment["read_end"],
|
|
266
|
+
"genome_mapq": genome_segment["mapq"],
|
|
267
|
+
"vector_mapq": vector_segment["mapq"],
|
|
268
|
+
"genome_identity": estimate_identity(genome_segment),
|
|
269
|
+
"vector_identity": estimate_identity(vector_segment),
|
|
270
|
+
}
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
# 左侧最近的外源段(外源段终点 <= 基因组段起点)
|
|
274
|
+
left_vectors = [
|
|
275
|
+
segment
|
|
276
|
+
for segment in exog_segments
|
|
277
|
+
if segment["read_end"] <= genome_segment["read_start"]
|
|
278
|
+
]
|
|
279
|
+
if left_vectors:
|
|
280
|
+
vector_segment = max(left_vectors, key=lambda segment: segment["read_end"])
|
|
281
|
+
gap_bp = genome_segment["read_start"] - vector_segment["read_end"]
|
|
282
|
+
if gap_bp <= max_gap_bp:
|
|
283
|
+
# 外源在 read 左侧:链 + 时基因组在边界右侧,链 - 时在左侧
|
|
284
|
+
if genome_segment["strand"] == "+":
|
|
285
|
+
genome_side, boundary = "genome_right", genome_segment["ref_start"]
|
|
286
|
+
else:
|
|
287
|
+
genome_side, boundary = "genome_left", genome_segment["ref_end"]
|
|
288
|
+
events.append(
|
|
289
|
+
{
|
|
290
|
+
"read_name": read_name,
|
|
291
|
+
"chrom": genome_segment["chrom"],
|
|
292
|
+
"genome_side": genome_side,
|
|
293
|
+
"boundary": boundary,
|
|
294
|
+
"vector_name": vector_segment["chrom"],
|
|
295
|
+
"gap_bp": gap_bp,
|
|
296
|
+
"genome_read_start": genome_segment["read_start"],
|
|
297
|
+
"genome_read_end": genome_segment["read_end"],
|
|
298
|
+
"vector_read_start": vector_segment["read_start"],
|
|
299
|
+
"vector_read_end": vector_segment["read_end"],
|
|
300
|
+
"genome_mapq": genome_segment["mapq"],
|
|
301
|
+
"vector_mapq": vector_segment["mapq"],
|
|
302
|
+
"genome_identity": estimate_identity(genome_segment),
|
|
303
|
+
"vector_identity": estimate_identity(vector_segment),
|
|
304
|
+
}
|
|
305
|
+
)
|
|
306
|
+
return events
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def estimate_identity(segment):
|
|
310
|
+
"""用简化 CIGAR 的 M 长度与 NM 估算一致率(没有 NM 时按 1.0)。
|
|
311
|
+
|
|
312
|
+
输入示例: segment={"match_columns": 1353, "nm": 3, "inserted_bases": 0, "deleted_bases": 0}
|
|
313
|
+
输出示例: 0.9978
|
|
314
|
+
"""
|
|
315
|
+
match_columns = segment.get("match_columns") or 0
|
|
316
|
+
if match_columns <= 0:
|
|
317
|
+
return 1.0
|
|
318
|
+
edits = (
|
|
319
|
+
(segment.get("nm") or 0)
|
|
320
|
+
- (segment.get("inserted_bases") or 0)
|
|
321
|
+
- (segment.get("deleted_bases") or 0)
|
|
322
|
+
)
|
|
323
|
+
return max(0.0, 1.0 - max(0, edits) / match_columns)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def cluster_events_into_sites(events, site_gap_bp):
|
|
327
|
+
"""按 (染色体, genome_side) 聚位点:边界差 <= site_gap_bp 的事件归为同一位点。
|
|
328
|
+
|
|
329
|
+
输入示例: events=[{"chrom": "chr4", "genome_side": "genome_right",
|
|
330
|
+
"boundary": 3000000}, ...]
|
|
331
|
+
输出示例: [{"chrom": "chr4", "genome_side": "genome_right",
|
|
332
|
+
"boundary_min": 2999995, "boundary_max": 3000005,
|
|
333
|
+
"boundary_median": 3000000, "events": [...]}]
|
|
334
|
+
说明:单链接聚类(与上一个事件的边界差 <= 阈值就并入),不做两两全比较。
|
|
335
|
+
"""
|
|
336
|
+
grouped = collections.defaultdict(list)
|
|
337
|
+
for event in events:
|
|
338
|
+
grouped[(event["chrom"], event["genome_side"])].append(event)
|
|
339
|
+
|
|
340
|
+
sites = []
|
|
341
|
+
for (chrom, genome_side), site_events in grouped.items():
|
|
342
|
+
site_events.sort(key=lambda event: event["boundary"])
|
|
343
|
+
current = []
|
|
344
|
+
for event in site_events:
|
|
345
|
+
if current and event["boundary"] - current[-1]["boundary"] > site_gap_bp:
|
|
346
|
+
sites.append(build_site(current))
|
|
347
|
+
current = []
|
|
348
|
+
current.append(event)
|
|
349
|
+
if current:
|
|
350
|
+
sites.append(build_site(current))
|
|
351
|
+
|
|
352
|
+
sites.sort(key=lambda site: (site["chrom"], site["boundary_median"], site["genome_side"]))
|
|
353
|
+
for index, site in enumerate(sites, 1):
|
|
354
|
+
site["site_id"] = "S{0:02d}".format(index)
|
|
355
|
+
return sites
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def build_site(site_events):
|
|
359
|
+
"""把一个边界簇整理成位点记录(边界 min/max/中位数、reads、外源名)。
|
|
360
|
+
|
|
361
|
+
输入示例: site_events=[{... "boundary": 3000000, "vector_name": "insertA"}, ...]
|
|
362
|
+
输出示例: {"chrom": "chr1", "genome_side": "genome_right",
|
|
363
|
+
"boundary_min": 3000000, "boundary_max": 3000000,
|
|
364
|
+
"boundary_median": 3000000, "events": [...],
|
|
365
|
+
"read_names": {...}, "vector_names": {...}}
|
|
366
|
+
"""
|
|
367
|
+
boundaries = sorted(event["boundary"] for event in site_events)
|
|
368
|
+
return {
|
|
369
|
+
"chrom": site_events[0]["chrom"],
|
|
370
|
+
"genome_side": site_events[0]["genome_side"],
|
|
371
|
+
"boundary_min": boundaries[0],
|
|
372
|
+
"boundary_max": boundaries[-1],
|
|
373
|
+
"boundary_median": boundaries[len(boundaries) // 2],
|
|
374
|
+
"events": site_events,
|
|
375
|
+
"read_names": {event["read_name"] for event in site_events},
|
|
376
|
+
"vector_names": {event["vector_name"] for event in site_events},
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def group_sites_into_regions(sites, region_gap_bp):
|
|
381
|
+
"""把同染色体上相距 <= region_gap_bp 的位点归为一个区域(类别单位)。
|
|
382
|
+
|
|
383
|
+
输入示例: sites=[{"chrom": "chr4", "boundary_min": 3000000, ...},
|
|
384
|
+
{"chrom": "chr4", "boundary_min": 3000500, ...}]
|
|
385
|
+
输出示例: [{"region_id": "R01", "chrom": "chr4", "start": 3000000,
|
|
386
|
+
"end": 3000500, "sites": [...], "read_names": {...}}]
|
|
387
|
+
说明:左侧和右侧边界也会被归进同一区域(一个插入的两端);区域只是分类单位,
|
|
388
|
+
写表时仍保留每个位点的 genome_side 与精确边界。
|
|
389
|
+
"""
|
|
390
|
+
grouped = collections.defaultdict(list)
|
|
391
|
+
for site in sites:
|
|
392
|
+
grouped[site["chrom"]].append(site)
|
|
393
|
+
|
|
394
|
+
regions = []
|
|
395
|
+
for chrom, chrom_sites in grouped.items():
|
|
396
|
+
chrom_sites.sort(key=lambda site: site["boundary_min"])
|
|
397
|
+
current = []
|
|
398
|
+
for site in chrom_sites:
|
|
399
|
+
if (
|
|
400
|
+
current
|
|
401
|
+
and site["boundary_min"] - max(item["boundary_max"] for item in current)
|
|
402
|
+
> region_gap_bp
|
|
403
|
+
):
|
|
404
|
+
regions.append(build_region(chrom, current))
|
|
405
|
+
current = []
|
|
406
|
+
current.append(site)
|
|
407
|
+
if current:
|
|
408
|
+
regions.append(build_region(chrom, current))
|
|
409
|
+
|
|
410
|
+
regions.sort(key=lambda region: (region["chrom"], region["start"]))
|
|
411
|
+
for index, region in enumerate(regions, 1):
|
|
412
|
+
region["region_id"] = "R{0:02d}".format(index)
|
|
413
|
+
region["region_label"] = "{0}|{1}:{2}-{3}".format(
|
|
414
|
+
region["region_id"], region["chrom"], region["start"], region["end"]
|
|
415
|
+
)
|
|
416
|
+
for site in region["sites"]:
|
|
417
|
+
site["region_id"] = region["region_id"]
|
|
418
|
+
return regions
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def build_region(chrom, region_sites):
|
|
422
|
+
"""把一组位点整理成区域记录。
|
|
423
|
+
|
|
424
|
+
输入示例: region_sites=[site1, site2]
|
|
425
|
+
输出示例: {"chrom": chrom, "start": 3000000, "end": 3000500, "sites": [...],
|
|
426
|
+
"read_names": {...}, "vector_names": {...}}
|
|
427
|
+
"""
|
|
428
|
+
read_names = set()
|
|
429
|
+
vector_names = set()
|
|
430
|
+
for site in region_sites:
|
|
431
|
+
read_names |= site["read_names"]
|
|
432
|
+
vector_names |= site["vector_names"]
|
|
433
|
+
return {
|
|
434
|
+
"chrom": chrom,
|
|
435
|
+
"start": min(site["boundary_min"] for site in region_sites),
|
|
436
|
+
"end": max(site["boundary_max"] for site in region_sites),
|
|
437
|
+
"sites": region_sites,
|
|
438
|
+
"read_names": read_names,
|
|
439
|
+
"vector_names": vector_names,
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
### 输出 ###
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def write_read_classification(rows, output_path):
|
|
447
|
+
"""写有外源证据的 read 的分类表(一行一条 read)。
|
|
448
|
+
|
|
449
|
+
输入示例: rows=[{"read_name": "m.../ccs", "read_category": "junction", ...}]
|
|
450
|
+
输出示例: read_name read_length read_category n_exog_segments ...
|
|
451
|
+
"""
|
|
452
|
+
columns = [
|
|
453
|
+
"read_name",
|
|
454
|
+
"read_length",
|
|
455
|
+
"read_category",
|
|
456
|
+
"n_exog_segments",
|
|
457
|
+
"exog_names",
|
|
458
|
+
"exog_aln_bp",
|
|
459
|
+
"n_genome_segments",
|
|
460
|
+
"genome_aln_bp",
|
|
461
|
+
"genome_chroms",
|
|
462
|
+
"n_genome_segments_dropped",
|
|
463
|
+
"genome_aln_bp_dropped",
|
|
464
|
+
"max_mapq",
|
|
465
|
+
"notes",
|
|
466
|
+
]
|
|
467
|
+
table_rows = []
|
|
468
|
+
for row in rows:
|
|
469
|
+
table_rows.append(
|
|
470
|
+
[
|
|
471
|
+
row["read_name"],
|
|
472
|
+
row["read_length"],
|
|
473
|
+
row["read_category"],
|
|
474
|
+
row["n_exog_segments"],
|
|
475
|
+
",".join(row["exog_names"]) or "-",
|
|
476
|
+
row["exog_aln_bp"],
|
|
477
|
+
row["n_genome_segments"],
|
|
478
|
+
row["genome_aln_bp"],
|
|
479
|
+
",".join(row["genome_chroms"]) or "-",
|
|
480
|
+
row["n_genome_segments_dropped"],
|
|
481
|
+
row["genome_aln_bp_dropped"],
|
|
482
|
+
row["max_mapq"],
|
|
483
|
+
";".join(row["notes"]) or "-",
|
|
484
|
+
]
|
|
485
|
+
)
|
|
486
|
+
write_tsv(
|
|
487
|
+
output_path,
|
|
488
|
+
columns,
|
|
489
|
+
table_rows,
|
|
490
|
+
[
|
|
491
|
+
"只列有外源证据的 read;类别:junction = 同时有外源与基因组证据;"
|
|
492
|
+
"vector_region = 只有外源证据。genome_only/other 不进入本表(不加载非目标 reads)。",
|
|
493
|
+
"坐标:边界为 0-based;genome_aln_bp 是 read 轴上基因组比对区间的并集。",
|
|
494
|
+
],
|
|
495
|
+
)
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def write_junction_events(events, output_path):
|
|
499
|
+
"""写接头事件表(每个基因组段紧邻外源段的事件一行)。
|
|
500
|
+
|
|
501
|
+
输入示例: events=[{"read_name": "m.../ccs", "chrom": "chr1", ...}]
|
|
502
|
+
输出示例: read_name chrom side boundary region_id site_id vector_name gap_bp ...
|
|
503
|
+
"""
|
|
504
|
+
columns = [
|
|
505
|
+
"read_name",
|
|
506
|
+
"chrom",
|
|
507
|
+
"genome_side",
|
|
508
|
+
"boundary",
|
|
509
|
+
"region_id",
|
|
510
|
+
"site_id",
|
|
511
|
+
"vector_name",
|
|
512
|
+
"gap_bp",
|
|
513
|
+
"genome_read_start",
|
|
514
|
+
"genome_read_end",
|
|
515
|
+
"vector_read_start",
|
|
516
|
+
"vector_read_end",
|
|
517
|
+
"genome_mapq",
|
|
518
|
+
"vector_mapq",
|
|
519
|
+
"genome_identity",
|
|
520
|
+
"vector_identity",
|
|
521
|
+
]
|
|
522
|
+
rows = []
|
|
523
|
+
for event in events:
|
|
524
|
+
rows.append(
|
|
525
|
+
[
|
|
526
|
+
event["read_name"],
|
|
527
|
+
event["chrom"],
|
|
528
|
+
event["genome_side"],
|
|
529
|
+
event["boundary"],
|
|
530
|
+
event["region_id"],
|
|
531
|
+
event["site_id"],
|
|
532
|
+
event["vector_name"],
|
|
533
|
+
event["gap_bp"],
|
|
534
|
+
event["genome_read_start"],
|
|
535
|
+
event["genome_read_end"],
|
|
536
|
+
event["vector_read_start"],
|
|
537
|
+
event["vector_read_end"],
|
|
538
|
+
event["genome_mapq"],
|
|
539
|
+
event["vector_mapq"],
|
|
540
|
+
"{0:.4f}".format(event["genome_identity"]),
|
|
541
|
+
"{0:.4f}".format(event["vector_identity"]),
|
|
542
|
+
]
|
|
543
|
+
)
|
|
544
|
+
write_tsv(
|
|
545
|
+
output_path,
|
|
546
|
+
columns,
|
|
547
|
+
rows,
|
|
548
|
+
[
|
|
549
|
+
"边界 boundary 为 0-based;genome_side=genome_left 时等于最后一个基因组碱基的 1-based 坐标,"
|
|
550
|
+
"genome_side=genome_right 时等于插入后第一个基因组碱基的 0-based 坐标。",
|
|
551
|
+
"gap_bp:基因组段与外源段在 read 轴上的间隔(0 = 紧邻/重叠)。",
|
|
552
|
+
],
|
|
553
|
+
)
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def write_junction_sites(sites, output_path):
|
|
557
|
+
"""写接头位点表(同 side、边界差 <= 20 bp 的事件聚成一行)。
|
|
558
|
+
|
|
559
|
+
输入示例: sites=[{"site_id": "S01", "region_id": "R01", ...}]
|
|
560
|
+
输出示例: site_id region_id chrom side boundary_min boundary_max boundary_median ...
|
|
561
|
+
"""
|
|
562
|
+
columns = [
|
|
563
|
+
"site_id",
|
|
564
|
+
"region_id",
|
|
565
|
+
"chrom",
|
|
566
|
+
"genome_side",
|
|
567
|
+
"boundary_min",
|
|
568
|
+
"boundary_max",
|
|
569
|
+
"boundary_median",
|
|
570
|
+
"n_events",
|
|
571
|
+
"n_reads",
|
|
572
|
+
"exog_names",
|
|
573
|
+
"read_names",
|
|
574
|
+
]
|
|
575
|
+
rows = []
|
|
576
|
+
for site in sites:
|
|
577
|
+
rows.append(
|
|
578
|
+
[
|
|
579
|
+
site["site_id"],
|
|
580
|
+
site["region_id"],
|
|
581
|
+
site["chrom"],
|
|
582
|
+
site["genome_side"],
|
|
583
|
+
site["boundary_min"],
|
|
584
|
+
site["boundary_max"],
|
|
585
|
+
site["boundary_median"],
|
|
586
|
+
len(site["events"]),
|
|
587
|
+
len(site["read_names"]),
|
|
588
|
+
",".join(sorted(site["vector_names"])),
|
|
589
|
+
",".join(sorted(site["read_names"])),
|
|
590
|
+
]
|
|
591
|
+
)
|
|
592
|
+
write_tsv(
|
|
593
|
+
output_path,
|
|
594
|
+
columns,
|
|
595
|
+
rows,
|
|
596
|
+
[
|
|
597
|
+
"位点 = 同染色体、同 genome_side、边界差 <= --site-gap-bp(默认 20 bp)的接头事件。",
|
|
598
|
+
"boundary_* 为 0-based 边界;genome_side=genome_left 表示基因组在边界左侧(插入从边界开始)。",
|
|
599
|
+
],
|
|
600
|
+
)
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def write_locus_regions(regions, output_path):
|
|
604
|
+
"""写位点区域表(区域的类别单位,供后续按类组装)。
|
|
605
|
+
|
|
606
|
+
输入示例: regions=[{"region_id": "R01", "chrom": "chr1", ...}]
|
|
607
|
+
输出示例: region_id region_label chrom start end n_sites n_reads
|
|
608
|
+
genome_sides site_ids exog_names
|
|
609
|
+
"""
|
|
610
|
+
columns = [
|
|
611
|
+
"region_id",
|
|
612
|
+
"region_label",
|
|
613
|
+
"chrom",
|
|
614
|
+
"start",
|
|
615
|
+
"end",
|
|
616
|
+
"n_sites",
|
|
617
|
+
"n_reads",
|
|
618
|
+
"genome_sides",
|
|
619
|
+
"site_ids",
|
|
620
|
+
"exog_names",
|
|
621
|
+
]
|
|
622
|
+
rows = []
|
|
623
|
+
for region in regions:
|
|
624
|
+
rows.append(
|
|
625
|
+
[
|
|
626
|
+
region["region_id"],
|
|
627
|
+
region["region_label"],
|
|
628
|
+
region["chrom"],
|
|
629
|
+
region["start"],
|
|
630
|
+
region["end"],
|
|
631
|
+
len(region["sites"]),
|
|
632
|
+
len(region["read_names"]),
|
|
633
|
+
",".join(sorted({site["genome_side"] for site in region["sites"]})),
|
|
634
|
+
",".join(site["site_id"] for site in region["sites"]),
|
|
635
|
+
",".join(sorted(region["vector_names"])),
|
|
636
|
+
]
|
|
637
|
+
)
|
|
638
|
+
write_tsv(
|
|
639
|
+
output_path,
|
|
640
|
+
columns,
|
|
641
|
+
rows,
|
|
642
|
+
[
|
|
643
|
+
"区域 = 同染色体上位点边界相距 <= --region-gap-bp(默认 200 kb)的位点集合;"
|
|
644
|
+
"区域是后续“按接头位点分类”的类别单位。",
|
|
645
|
+
"坐标 start/end 为 0-based 边界范围。",
|
|
646
|
+
],
|
|
647
|
+
)
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
def write_fastq_subsets(reads_path, read_names_by_output_path):
|
|
651
|
+
"""一次扫描 FASTQ,把所有子集写到各自文件(保持原始 read 顺序)。
|
|
652
|
+
|
|
653
|
+
输入示例: write_fastq_subsets("input.fastq", {
|
|
654
|
+
"out/related.fastq": {"r1", "r2"},
|
|
655
|
+
"out/junction.fastq": {"r1"},
|
|
656
|
+
})
|
|
657
|
+
输出示例: out/related.fastq 有 r1/r2,out/junction.fastq 有 r1
|
|
658
|
+
说明:旧实现每个子集都重新扫一遍 FASTQ(related/vector/junction/每个区域各一次);
|
|
659
|
+
这里只扫一遍,内存只保存目标 read 名集合,不保存序列。
|
|
660
|
+
"""
|
|
661
|
+
handles = {}
|
|
662
|
+
total_reads = 0
|
|
663
|
+
try:
|
|
664
|
+
for output_path in read_names_by_output_path:
|
|
665
|
+
os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True)
|
|
666
|
+
handles[output_path] = open(output_path, "w")
|
|
667
|
+
with pysam.FastxFile(reads_path) as fastq_file:
|
|
668
|
+
for record in fastq_file:
|
|
669
|
+
total_reads += 1
|
|
670
|
+
for output_path, selected_read_names in read_names_by_output_path.items():
|
|
671
|
+
if record.name in selected_read_names:
|
|
672
|
+
handles[output_path].write(
|
|
673
|
+
"@{0}\n{1}\n+\n{2}\n".format(
|
|
674
|
+
record.name, record.sequence, record.quality
|
|
675
|
+
)
|
|
676
|
+
)
|
|
677
|
+
finally:
|
|
678
|
+
for handle in handles.values():
|
|
679
|
+
handle.close()
|
|
680
|
+
return total_reads
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
### 主流程 ###
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
@dataclass(slots=True)
|
|
687
|
+
class ClassifySettings:
|
|
688
|
+
"""``alnkit long classify`` 的全部入参(与旧脚本参数一一对应)。"""
|
|
689
|
+
|
|
690
|
+
bam: str = ""
|
|
691
|
+
reads: str = ""
|
|
692
|
+
merged_fai: str = ""
|
|
693
|
+
exog: tuple[str, ...] = ()
|
|
694
|
+
outdir: str = "."
|
|
695
|
+
min_mapq: int = 20
|
|
696
|
+
min_exog_block_bp: int = 100
|
|
697
|
+
contig_edge_bp: int = 100
|
|
698
|
+
short_genome_block_bp: int = 500
|
|
699
|
+
junction_max_gap_bp: int = 10_000
|
|
700
|
+
site_gap_bp: int = 20
|
|
701
|
+
region_gap_bp: int = 200_000
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
def run(settings: ClassifySettings) -> dict[str, object]:
|
|
705
|
+
os.makedirs(settings.outdir, exist_ok=True)
|
|
706
|
+
exog_names = tuple(name.strip() for name in settings.exog if name and name.strip())
|
|
707
|
+
if not exog_names:
|
|
708
|
+
raise UsageError("--exog 至少要给一个非空的外源序列名")
|
|
709
|
+
chrom_lengths = load_fai_lengths(settings.merged_fai)
|
|
710
|
+
|
|
711
|
+
# 第一步:只找有合格外源比对的 read(通常远少于全部 reads)
|
|
712
|
+
target_read_names = find_target_read_names(
|
|
713
|
+
settings.bam, exog_names, settings.min_mapq, settings.min_exog_block_bp
|
|
714
|
+
)
|
|
715
|
+
# 第二步:只加载这些 read 的非 secondary 比对段
|
|
716
|
+
segments_by_read = load_segments_by_read(settings.bam, chrom_lengths, target_read_names)
|
|
717
|
+
read_lengths = {
|
|
718
|
+
read_name: segments[0]["read_length"] for read_name, segments in segments_by_read.items()
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
### 1. 逐段判定:外源证据 / 基因组证据 / 可疑错配 ###
|
|
722
|
+
|
|
723
|
+
segment_rows = []
|
|
724
|
+
read_records = []
|
|
725
|
+
for read_name in read_lengths:
|
|
726
|
+
segments = segments_by_read.get(read_name, [])
|
|
727
|
+
exog_segments = []
|
|
728
|
+
genome_segments = []
|
|
729
|
+
dropped_genome_segments = []
|
|
730
|
+
for segment in segments:
|
|
731
|
+
role, kept, reason = classify_segment(segment, exog_names, settings)
|
|
732
|
+
segment_rows.append(
|
|
733
|
+
[
|
|
734
|
+
read_name,
|
|
735
|
+
role,
|
|
736
|
+
segment["chrom"],
|
|
737
|
+
segment["chrom_length"],
|
|
738
|
+
segment["ref_start"],
|
|
739
|
+
segment["ref_end"],
|
|
740
|
+
segment["read_start"],
|
|
741
|
+
segment["read_end"],
|
|
742
|
+
segment["strand"],
|
|
743
|
+
segment["mapq"],
|
|
744
|
+
segment["ref_aln_len"],
|
|
745
|
+
segment["read_aln_len"],
|
|
746
|
+
segment["cigar"],
|
|
747
|
+
int(kept),
|
|
748
|
+
reason,
|
|
749
|
+
]
|
|
750
|
+
)
|
|
751
|
+
if not kept:
|
|
752
|
+
if role == "genome":
|
|
753
|
+
dropped_genome_segments.append(segment)
|
|
754
|
+
continue
|
|
755
|
+
if role == "exog":
|
|
756
|
+
exog_segments.append(segment)
|
|
757
|
+
elif role == "genome":
|
|
758
|
+
genome_segments.append(segment)
|
|
759
|
+
|
|
760
|
+
### 2. 接头事件 ###
|
|
761
|
+
|
|
762
|
+
events = (
|
|
763
|
+
find_junction_events(
|
|
764
|
+
read_name, exog_segments, genome_segments, settings.junction_max_gap_bp
|
|
765
|
+
)
|
|
766
|
+
if exog_segments and genome_segments
|
|
767
|
+
else []
|
|
768
|
+
)
|
|
769
|
+
|
|
770
|
+
if exog_segments and genome_segments:
|
|
771
|
+
read_category = "junction"
|
|
772
|
+
elif exog_segments:
|
|
773
|
+
read_category = "vector_region"
|
|
774
|
+
elif genome_segments:
|
|
775
|
+
read_category = "genome_only"
|
|
776
|
+
else:
|
|
777
|
+
read_category = "other"
|
|
778
|
+
|
|
779
|
+
notes = []
|
|
780
|
+
if dropped_genome_segments:
|
|
781
|
+
notes.append("genome_blocks_dropped_as_contig_edge_artifact")
|
|
782
|
+
if read_category == "junction" and not events:
|
|
783
|
+
notes.append("genome_and_vector_not_adjacent")
|
|
784
|
+
|
|
785
|
+
genome_intervals = merge_intervals(
|
|
786
|
+
[(segment["read_start"], segment["read_end"]) for segment in genome_segments]
|
|
787
|
+
)
|
|
788
|
+
genome_aln_bp = sum(end - start for start, end in genome_intervals)
|
|
789
|
+
exog_aln_bp = sum(segment["ref_aln_len"] for segment in exog_segments)
|
|
790
|
+
all_mapqs = [segment["mapq"] for segment in segments]
|
|
791
|
+
|
|
792
|
+
read_records.append(
|
|
793
|
+
{
|
|
794
|
+
"read_name": read_name,
|
|
795
|
+
"read_length": read_lengths[read_name],
|
|
796
|
+
"read_category": read_category,
|
|
797
|
+
"events": events,
|
|
798
|
+
"region_ids": [],
|
|
799
|
+
"n_exog_segments": len(exog_segments),
|
|
800
|
+
"exog_names": sorted({segment["chrom"] for segment in exog_segments}),
|
|
801
|
+
"exog_aln_bp": exog_aln_bp,
|
|
802
|
+
"n_genome_segments": len(genome_segments),
|
|
803
|
+
"genome_aln_bp": genome_aln_bp,
|
|
804
|
+
"genome_chroms": sorted({segment["chrom"] for segment in genome_segments}),
|
|
805
|
+
"n_genome_segments_dropped": len(dropped_genome_segments),
|
|
806
|
+
"genome_aln_bp_dropped": sum(
|
|
807
|
+
segment["ref_aln_len"] for segment in dropped_genome_segments
|
|
808
|
+
),
|
|
809
|
+
"max_mapq": max(all_mapqs) if all_mapqs else 0,
|
|
810
|
+
"notes": notes,
|
|
811
|
+
}
|
|
812
|
+
)
|
|
813
|
+
|
|
814
|
+
### 3. 位点、区域聚类,并把事件写回 read ###
|
|
815
|
+
|
|
816
|
+
all_events = [event for record in read_records for event in record["events"]]
|
|
817
|
+
sites = cluster_events_into_sites(all_events, settings.site_gap_bp)
|
|
818
|
+
regions = group_sites_into_regions(sites, settings.region_gap_bp)
|
|
819
|
+
|
|
820
|
+
for site in sites:
|
|
821
|
+
for event in site["events"]:
|
|
822
|
+
event["site_id"] = site["site_id"]
|
|
823
|
+
event["region_id"] = site["region_id"]
|
|
824
|
+
|
|
825
|
+
for record in read_records:
|
|
826
|
+
record["region_ids"] = sorted({event["region_id"] for event in record["events"]})
|
|
827
|
+
|
|
828
|
+
### 4. 输出 ###
|
|
829
|
+
|
|
830
|
+
write_tsv(
|
|
831
|
+
os.path.join(settings.outdir, "alignment_segments.tsv"),
|
|
832
|
+
[
|
|
833
|
+
"read_name",
|
|
834
|
+
"segment_role",
|
|
835
|
+
"chrom",
|
|
836
|
+
"chrom_length",
|
|
837
|
+
"ref_start",
|
|
838
|
+
"ref_end",
|
|
839
|
+
"read_start",
|
|
840
|
+
"read_end",
|
|
841
|
+
"strand",
|
|
842
|
+
"mapq",
|
|
843
|
+
"ref_aln_len",
|
|
844
|
+
"read_aln_len",
|
|
845
|
+
"simplified_cigar",
|
|
846
|
+
"kept",
|
|
847
|
+
"drop_reason",
|
|
848
|
+
],
|
|
849
|
+
segment_rows,
|
|
850
|
+
[
|
|
851
|
+
"只列有外源证据的 read 的非 secondary 比对段;kept=0 的段不参与分类。",
|
|
852
|
+
"read_start/read_end 已统一到正向 read 轴(见 scripts/alignment_table.py)。",
|
|
853
|
+
"simplified_cigar:把两个 M 之间总长 <=5 bp 的 I/D 并进 M 后的简化视图,"
|
|
854
|
+
"不是 BAM 原始 CIGAR;原始 CIGAR 未保存在本表。",
|
|
855
|
+
],
|
|
856
|
+
)
|
|
857
|
+
write_read_classification(
|
|
858
|
+
read_records, os.path.join(settings.outdir, "read_classification.tsv")
|
|
859
|
+
)
|
|
860
|
+
write_junction_events(all_events, os.path.join(settings.outdir, "junction_events.tsv"))
|
|
861
|
+
write_junction_sites(sites, os.path.join(settings.outdir, "junction_sites.tsv"))
|
|
862
|
+
write_locus_regions(regions, os.path.join(settings.outdir, "locus_regions.tsv"))
|
|
863
|
+
|
|
864
|
+
related_read_names = {
|
|
865
|
+
record["read_name"]
|
|
866
|
+
for record in read_records
|
|
867
|
+
if record["read_category"] in ("junction", "vector_region")
|
|
868
|
+
}
|
|
869
|
+
vector_read_names = {
|
|
870
|
+
record["read_name"] for record in read_records if record["read_category"] == "vector_region"
|
|
871
|
+
}
|
|
872
|
+
junction_read_names = {
|
|
873
|
+
record["read_name"] for record in read_records if record["read_category"] == "junction"
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
regions_dir = os.path.join(settings.outdir, "regions")
|
|
877
|
+
os.makedirs(regions_dir, exist_ok=True)
|
|
878
|
+
read_names_by_output_path = {
|
|
879
|
+
os.path.join(settings.outdir, "related_reads.fastq"): related_read_names,
|
|
880
|
+
os.path.join(settings.outdir, "vector_region_reads.fastq"): vector_read_names,
|
|
881
|
+
os.path.join(settings.outdir, "junction_reads.fastq"): junction_read_names,
|
|
882
|
+
}
|
|
883
|
+
for region in regions:
|
|
884
|
+
region_dir = os.path.join(regions_dir, region["region_id"])
|
|
885
|
+
os.makedirs(region_dir, exist_ok=True)
|
|
886
|
+
read_names_by_output_path[os.path.join(region_dir, "junction_reads.fastq")] = region[
|
|
887
|
+
"read_names"
|
|
888
|
+
]
|
|
889
|
+
total_reads = write_fastq_subsets(settings.reads, read_names_by_output_path)
|
|
890
|
+
|
|
891
|
+
### 5. 输出汇总(保留“非目标 reads 被跳过”的数量,不输出逐条记录) ###
|
|
892
|
+
|
|
893
|
+
target_read_count = len(read_records)
|
|
894
|
+
junction_count = len(junction_read_names)
|
|
895
|
+
vector_count = len(vector_read_names)
|
|
896
|
+
non_target_count = max(0, total_reads - target_read_count)
|
|
897
|
+
target_fraction = (target_read_count / total_reads) if total_reads else 0.0
|
|
898
|
+
write_tsv(
|
|
899
|
+
os.path.join(settings.outdir, "classification_summary.tsv"),
|
|
900
|
+
[
|
|
901
|
+
"total_reads",
|
|
902
|
+
"target_reads",
|
|
903
|
+
"junction",
|
|
904
|
+
"vector_region",
|
|
905
|
+
"non_target_reads",
|
|
906
|
+
"target_fraction",
|
|
907
|
+
],
|
|
908
|
+
[
|
|
909
|
+
[
|
|
910
|
+
total_reads,
|
|
911
|
+
target_read_count,
|
|
912
|
+
junction_count,
|
|
913
|
+
vector_count,
|
|
914
|
+
non_target_count,
|
|
915
|
+
"{0:.4f}".format(target_fraction),
|
|
916
|
+
]
|
|
917
|
+
],
|
|
918
|
+
[
|
|
919
|
+
"total_reads:FASTQ 总 reads(一次扫描统计);target_reads:有合格外源比对、进入分类的 reads;",
|
|
920
|
+
"non_target_reads = total_reads - target_reads;这些 reads 不加载比对段、不输出逐条记录。",
|
|
921
|
+
],
|
|
922
|
+
)
|
|
923
|
+
|
|
924
|
+
### 6. 控制台摘要 ###
|
|
925
|
+
|
|
926
|
+
category_counts = collections.Counter(record["read_category"] for record in read_records)
|
|
927
|
+
print("有外源证据的 read 分类:{0}".format(dict(category_counts)))
|
|
928
|
+
print(
|
|
929
|
+
"FASTQ 总 reads:{0};目标 reads:{1};非目标跳过:{2}(不加载、不输出)".format(
|
|
930
|
+
total_reads, target_read_count, non_target_count
|
|
931
|
+
)
|
|
932
|
+
)
|
|
933
|
+
print("接头位点 {0} 个、区域 {1} 个".format(len(sites), len(regions)))
|
|
934
|
+
for region in regions:
|
|
935
|
+
print(
|
|
936
|
+
" {0} {1} sites={2} reads={3} exog={4}".format(
|
|
937
|
+
region["region_id"],
|
|
938
|
+
region["region_label"],
|
|
939
|
+
len(region["sites"]),
|
|
940
|
+
len(region["read_names"]),
|
|
941
|
+
",".join(sorted(region["vector_names"])),
|
|
942
|
+
)
|
|
943
|
+
)
|
|
944
|
+
print(
|
|
945
|
+
"相关 reads:{0} 条(载体区域 {1}、接头 {2})".format(
|
|
946
|
+
len(related_read_names), len(vector_read_names), len(junction_read_names)
|
|
947
|
+
)
|
|
948
|
+
)
|
|
949
|
+
|
|
950
|
+
return {
|
|
951
|
+
"outdir": settings.outdir,
|
|
952
|
+
"total_reads": total_reads,
|
|
953
|
+
"target_reads": target_read_count,
|
|
954
|
+
"junction_reads": len(junction_read_names),
|
|
955
|
+
"vector_reads": len(vector_read_names),
|
|
956
|
+
"sites": len(sites),
|
|
957
|
+
"regions": len(regions),
|
|
958
|
+
}
|