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.
Files changed (49) hide show
  1. alnkit/__init__.py +11 -0
  2. alnkit/__main__.py +10 -0
  3. alnkit/_docs.py +53 -0
  4. alnkit/cli/__init__.py +53 -0
  5. alnkit/cli/__main__.py +10 -0
  6. alnkit/cli/_common.py +25 -0
  7. alnkit/cli/align.py +52 -0
  8. alnkit/cli/doctor.py +81 -0
  9. alnkit/cli/long.py +346 -0
  10. alnkit/cli/plot.py +293 -0
  11. alnkit/cli/ref.py +50 -0
  12. alnkit/cli/seg.py +131 -0
  13. alnkit/cli/selftest.py +308 -0
  14. alnkit/cli/short.py +245 -0
  15. alnkit/core/__init__.py +9 -0
  16. alnkit/core/bam_segments.py +183 -0
  17. alnkit/core/coords.py +129 -0
  18. alnkit/core/errors.py +25 -0
  19. alnkit/core/external.py +63 -0
  20. alnkit/core/fasta.py +83 -0
  21. alnkit/core/logging.py +25 -0
  22. alnkit/core/paf.py +52 -0
  23. alnkit/core/segments.py +253 -0
  24. alnkit/core/tables.py +340 -0
  25. alnkit/longread/__init__.py +5 -0
  26. alnkit/longread/assemble.py +453 -0
  27. alnkit/longread/breakpoints.py +910 -0
  28. alnkit/longread/classify.py +958 -0
  29. alnkit/longread/graph.py +174 -0
  30. alnkit/longread/locate.py +904 -0
  31. alnkit/plot/__init__.py +5 -0
  32. alnkit/plot/ribbon.py +1306 -0
  33. alnkit/plot/tracks.py +1035 -0
  34. alnkit/prepare.py +256 -0
  35. alnkit/py.typed +0 -0
  36. alnkit/shortread/__init__.py +20 -0
  37. alnkit/shortread/bam.py +527 -0
  38. alnkit/shortread/evidence.py +214 -0
  39. alnkit/shortread/interference.py +44 -0
  40. alnkit/shortread/merge.py +126 -0
  41. alnkit/shortread/model.py +301 -0
  42. alnkit/shortread/origin.py +507 -0
  43. alnkit/shortread/report.py +316 -0
  44. alnkit/shortread/scan.py +788 -0
  45. alnkit-0.2.0.dist-info/METADATA +163 -0
  46. alnkit-0.2.0.dist-info/RECORD +49 -0
  47. alnkit-0.2.0.dist-info/WHEEL +4 -0
  48. alnkit-0.2.0.dist-info/entry_points.txt +2 -0
  49. alnkit-0.2.0.dist-info/licenses/LICENSE +674 -0
@@ -0,0 +1,453 @@
1
+ #!/usr/bin/env python3
2
+ # ---------------------------------------------------------------------------
3
+ # A6:用 read-vs-read overlap 关系做贪心延伸组装(overlap_extension)
4
+ #
5
+ # 来源:upstream/scripts/overlap_assembly.py(370 行)的忠实移植。逻辑未改;
6
+ # 参数解析搬到 alnkit.cli.long、库层收 AssembleSettings、main() 改为 run()。
7
+ # 产物(contigs.fasta / assembly_stats.tsv / 汇总表)与旧脚本逐字节一致。
8
+ #
9
+ # **不输出 layout.tsv**:它没有任何下游消费者(`long locate` 只读 contigs.fasta),
10
+ # 而独立校验发现它记录的 contig 内坐标与最终 contig 序列逐位一致率只有约 28%
11
+ # (贪心延伸把 read 拼上去时的坐标记账问题,非本次移植引入;上游同样如此)。
12
+ # 与其发布一个“看不出来错在哪”的表,不如不发布;需要排障时可直接跑上游脚本
13
+ # `upstream/scripts/overlap_assembly.py` 复现该表。
14
+ #
15
+ # 思路:
16
+ # 每个类先拿一条最长的“接头 read”作种子(它带着参考基因组侧翼),
17
+ # 然后反复从 overlap 边里挑一条能把手上的 contig 往左/往右延长的 read,
18
+ # 把它的非重叠悬垂序列接到 contig 两端。没有 overlap 支持的 read 不会被硬塞进来。
19
+ #
20
+ # 输入:
21
+ # 05_breakpoints/classes/<class_id>/reads.fastq 该类 read 池
22
+ # 05_breakpoints/classes/<class_id>/junction_reads.fastq 接头 reads(优先作种子)
23
+ # 04_overlap_graph/overlaps.paf 全局 all-vs-all PAF
24
+ # 输出:
25
+ # 06_assembly/<class_id>/overlap_extension/contigs.fasta
26
+ # 06_assembly/<class_id>/overlap_extension/assembly_stats.tsv
27
+ # 06_assembly/overlap_assembly_summary.tsv
28
+ # 06_assembly/all_overlap_contigs.fasta
29
+ #
30
+ # 输入示例:
31
+ # --classes-dir 05_breakpoints/classes --paf 04_overlap_graph/overlaps.paf
32
+ # --outdir 06_assembly --min-overlap 500 --min-identity 0.90
33
+ # 输出示例(contigs.fasta):
34
+ # >oe_contig1
35
+ # ACGT...(contig1 长度 38291 bp)
36
+ # ---------------------------------------------------------------------------
37
+ from __future__ import annotations
38
+
39
+ import collections
40
+ import os
41
+ from dataclasses import dataclass
42
+
43
+ import pysam
44
+
45
+ from ..core.fasta import reverse_complement, write_fasta_records
46
+ from ..core.paf import parse_paf
47
+ from ..core.tables import write_legacy_tsv as write_tsv
48
+
49
+ ### 读取输入 ###
50
+
51
+
52
+ def read_fastq_sequences(fastq_path):
53
+ """读 fastq,返回 {read_name: 大写序列}。
54
+
55
+ 输入示例: read_fastq_sequences("classes/C03/reads.fastq")
56
+ 输出示例: {"m.../ccs": "ACGT...", ...}
57
+ """
58
+ return {record.name: record.sequence.upper() for record in pysam.FastxFile(fastq_path)}
59
+
60
+
61
+ def load_best_blocks(paf_path, read_names, min_overlap_bp, min_identity):
62
+ """读 PAF,只保留类内 read 对,并且每对只留 block_bp 最大的一条。
63
+
64
+ 输入示例: load_best_blocks("overlaps.paf", {"r1","r2"}, 500, 0.90)
65
+ 输出示例: {("r1","r2"): {"query": "r1", "target": "r2", "block_bp": 12000, ...}}
66
+ 说明:重复序列可能让同一对 read 有多条比对;这里只留最好的一条,
67
+ 拿不准的多重位置会在贪心延伸时被判为 ambiguous 并跳过。
68
+ """
69
+ best_blocks = {}
70
+ for block in parse_paf(paf_path):
71
+ if block["query"] == block["target"]:
72
+ continue
73
+ if block["query"] not in read_names or block["target"] not in read_names:
74
+ continue
75
+ if block["block_bp"] < min_overlap_bp or block["identity"] < min_identity:
76
+ continue
77
+ pair = tuple(sorted((block["query"], block["target"])))
78
+ previous = best_blocks.get(pair)
79
+ if previous is None or (block["block_bp"], block["identity"]) > (
80
+ previous["block_bp"],
81
+ previous["identity"],
82
+ ):
83
+ best_blocks[pair] = block
84
+ return best_blocks
85
+
86
+
87
+ def read_junction_read_names(junction_fastq_path):
88
+ """读接头 reads 的名字,用于优先选种子。
89
+
90
+ 输入示例: read_junction_read_names("classes/C03/junction_reads.fastq")
91
+ 输出示例: {"m.../ccs", ...}
92
+ """
93
+ return {record.name for record in pysam.FastxFile(junction_fastq_path)}
94
+
95
+
96
+ ### 由 overlap 推算 read 在 contig 上的位置 ###
97
+
98
+
99
+ def placement_from_block(
100
+ placed_name,
101
+ placed_start,
102
+ placed_direction,
103
+ placed_length,
104
+ unplaced_name,
105
+ unplaced_length,
106
+ block,
107
+ ):
108
+ """根据一条 overlap,推算未放置 read 在 contig 坐标系里的起点与方向。
109
+
110
+ 输入示例:
111
+ placed_name="p", placed_start=0, placed_direction=+1, placed_length=100
112
+ unplaced_name="u", unplaced_length=100
113
+ block={"query": "u", "target": "p", "strand": "+",
114
+ "query_start": 80, "query_end": 100, "target_start": 0, "target_end": 20}
115
+ 输出示例: (u_start=-80, u_direction=+1) # u 的前 80 bp 落在 contig 左边
116
+ 说明:
117
+ * 先把 overlap 归一成“placed 坐标 -> unplaced 坐标”的线性映射;
118
+ * 再结合 placed 在 contig 上的方向,得到 unplaced 的 contig 起点;
119
+ * unplaced_direction=+1 表示它的序列方向与 contig 一致,-1 表示需要反向互补。
120
+ """
121
+ if block["query"] == unplaced_name and block["target"] == placed_name:
122
+ # query = 未放置 read,target = 已放置 read
123
+ placed_position_at_start = block["target_start"]
124
+ placed_position_at_end = block["target_end"]
125
+ unplaced_coordinate_at_start = block["query_start"]
126
+ increasing = block["strand"] == "+"
127
+ elif block["query"] == placed_name and block["target"] == unplaced_name:
128
+ # query = 已放置 read,target = 未放置 read
129
+ placed_position_at_start = block["query_start"]
130
+ placed_position_at_end = block["query_end"]
131
+ unplaced_coordinate_at_start = block["target_start"]
132
+ increasing = block["strand"] == "+"
133
+ else:
134
+ return None
135
+
136
+ # 与 unplaced read 起点配对的是 placed read 的哪一端?
137
+ # +:起点对起点;-:起点对**终点**(反链是头尾相对,起点对起点会差 aligned_bp-1)
138
+ placed_position = placed_position_at_start if increasing else placed_position_at_end - 1
139
+
140
+ # placed read 的那个坐标在 contig 上的位置
141
+ if placed_direction == 1:
142
+ contig_position_at_start = placed_start + placed_position
143
+ else:
144
+ contig_position_at_start = placed_start + (placed_length - 1 - placed_position)
145
+
146
+ unplaced_direction = placed_direction * (1 if increasing else -1)
147
+ if unplaced_direction == 1:
148
+ unplaced_start = contig_position_at_start - unplaced_coordinate_at_start
149
+ else:
150
+ unplaced_start = (
151
+ contig_position_at_start + unplaced_coordinate_at_start - (unplaced_length - 1)
152
+ )
153
+ return unplaced_start, unplaced_direction
154
+
155
+
156
+ def extension_lengths(unplaced_start, unplaced_length, contig_start, contig_end):
157
+ """算这条 read 能把 contig 往左/右各延长多少 bp。
158
+
159
+ 输入示例: extension_lengths(-80, 100, 0, 100) -> 输出示例: (80, 0)
160
+ """
161
+ left_extension = max(0, contig_start - unplaced_start)
162
+ right_extension = max(0, (unplaced_start + unplaced_length) - contig_end)
163
+ return left_extension, right_extension
164
+
165
+
166
+ def choose_placement(candidate_placements, agreement_bp):
167
+ """从多个邻居给出的位置里挑一个;位置互相矛盾就返回 None(判为 ambiguous)。
168
+
169
+ 输入示例: candidate_placements=[{"unplaced_start": -80, "unplaced_direction": 1,
170
+ "left_extension": 80, "right_extension": 0,
171
+ "overlap_score": 9000}, ...]
172
+ 输出示例: {"unplaced_start": -80, "unplaced_direction": 1, ...} 或 None
173
+ 说明:先把方向一致、起点相差 <= agreement_bp 的候选归为一组;
174
+ 取票数最多、延长量最大的一组;如果最大票数组和次大组打平,判为 ambiguous。
175
+ """
176
+ if not candidate_placements:
177
+ return None
178
+
179
+ groups = []
180
+ for candidate in sorted(candidate_placements, key=lambda item: item["unplaced_start"]):
181
+ for group in groups:
182
+ representative = group[0]
183
+ if (
184
+ candidate["unplaced_direction"] == representative["unplaced_direction"]
185
+ and abs(candidate["unplaced_start"] - representative["unplaced_start"])
186
+ <= agreement_bp
187
+ ):
188
+ group.append(candidate)
189
+ break
190
+ else:
191
+ groups.append([candidate])
192
+
193
+ def group_score(group):
194
+ representative = group[0]
195
+ return (
196
+ len(group),
197
+ representative["left_extension"] + representative["right_extension"],
198
+ representative["overlap_score"],
199
+ )
200
+
201
+ groups.sort(key=group_score, reverse=True)
202
+ best_group = groups[0]
203
+ if len(groups) > 1 and len(best_group) == len(groups[1]):
204
+ return None
205
+ return max(
206
+ best_group,
207
+ key=lambda item: (item["left_extension"] + item["right_extension"], item["overlap_score"]),
208
+ )
209
+
210
+
211
+ ### 贪心延伸主逻辑 ###
212
+
213
+
214
+ def extend_contig(
215
+ contig_sequence, contig_start, contig_end, read_sequence, read_start, read_direction
216
+ ):
217
+ """把一条 read 的悬垂部分接到 contig 两端,返回新的 (序列, 起点, 终点)。
218
+
219
+ 输入示例: contig="ACGT"(坐标 0-4),read 放在 -2,方向 +1,read="TTACGT"
220
+ -> 左悬垂 "TT" + "ACGT" = "TTACGT",新起点 -2,终点 4
221
+ 输出示例: ("TTACGT", -2, 4)
222
+ """
223
+ oriented_sequence = read_sequence if read_direction == 1 else reverse_complement(read_sequence)
224
+ left_extension = max(0, contig_start - read_start)
225
+ right_extension = max(0, (read_start + len(read_sequence)) - contig_end)
226
+ left_bases = oriented_sequence[:left_extension] if left_extension else ""
227
+ right_bases = (
228
+ oriented_sequence[len(oriented_sequence) - right_extension :] if right_extension else ""
229
+ )
230
+ return (
231
+ left_bases + contig_sequence + right_bases,
232
+ contig_start - left_extension,
233
+ contig_end + right_extension,
234
+ )
235
+
236
+
237
+ def assemble_one_class(read_sequences, junction_read_names, best_blocks, agreement_bp):
238
+ """对一个类做贪心延伸,返回 contig 列表与布局信息。
239
+
240
+ 输入示例: read_sequences={"j1": "...", "v1": "..."}, junction_read_names={"j1"},
241
+ best_blocks={("j1","v1"): {block}}
242
+ 输出示例: [{"name": "oe_contig1", "sequence": "...",
243
+ "n_reads": 12}]
244
+ 说明:
245
+ * 种子优先选最长的接头 read(带基因组锚点);接头 read 用完后才用最长载体 read;
246
+ * 每一轮挑“延长量最大”的候选;位置互相矛盾(重复序列)的 read 先跳过;
247
+ * 没有 overlap 连到当前 contig 的 read 会另起一条 contig(不硬拼)。
248
+ """
249
+ pair_blocks = collections.defaultdict(list)
250
+ for pair, block in best_blocks.items():
251
+ pair_blocks[pair].append(block)
252
+
253
+ unplaced = set(read_sequences)
254
+ contigs = []
255
+
256
+ while unplaced:
257
+ seed_candidates = unplaced & junction_read_names
258
+ seed_pool = seed_candidates if seed_candidates else unplaced
259
+ # 排序后再取最大:并列时按名字定序,避免 set 迭代顺序让结果随 PYTHONHASHSEED 漂
260
+ seed_name = max(sorted(seed_pool), key=lambda name: len(read_sequences[name]))
261
+ unplaced.remove(seed_name)
262
+
263
+ contig_sequence = read_sequences[seed_name]
264
+ contig_start, contig_end = 0, len(contig_sequence)
265
+ placed = {seed_name: (0, 1)}
266
+ placed_reads = [[seed_name, 0, len(contig_sequence), "+", 1]]
267
+
268
+ progress = True
269
+ while progress:
270
+ progress = False
271
+ candidates = []
272
+ for unplaced_name in sorted(unplaced):
273
+ candidate_placements = []
274
+ for placed_name, (placed_start, placed_direction) in placed.items():
275
+ pair = tuple(sorted((unplaced_name, placed_name)))
276
+ for block in pair_blocks.get(pair, []):
277
+ placement = placement_from_block(
278
+ placed_name,
279
+ placed_start,
280
+ placed_direction,
281
+ len(read_sequences[placed_name]),
282
+ unplaced_name,
283
+ len(read_sequences[unplaced_name]),
284
+ block,
285
+ )
286
+ if placement is None:
287
+ continue
288
+ unplaced_start, unplaced_direction = placement
289
+ left_extension, right_extension = extension_lengths(
290
+ unplaced_start,
291
+ len(read_sequences[unplaced_name]),
292
+ contig_start,
293
+ contig_end,
294
+ )
295
+ candidate_placements.append(
296
+ {
297
+ "unplaced_start": unplaced_start,
298
+ "unplaced_direction": unplaced_direction,
299
+ "left_extension": left_extension,
300
+ "right_extension": right_extension,
301
+ "overlap_score": block["block_bp"] * block["identity"],
302
+ }
303
+ )
304
+ chosen = choose_placement(candidate_placements, agreement_bp)
305
+ if chosen is None:
306
+ continue
307
+ candidates.append((chosen, unplaced_name))
308
+
309
+ if not candidates:
310
+ break
311
+
312
+ chosen, unplaced_name = max(
313
+ candidates,
314
+ key=lambda item: (
315
+ item[0]["left_extension"] + item[0]["right_extension"],
316
+ item[0]["overlap_score"],
317
+ ),
318
+ )
319
+ contig_sequence, contig_start, contig_end = extend_contig(
320
+ contig_sequence,
321
+ contig_start,
322
+ contig_end,
323
+ read_sequences[unplaced_name],
324
+ chosen["unplaced_start"],
325
+ chosen["unplaced_direction"],
326
+ )
327
+ placed[unplaced_name] = (chosen["unplaced_start"], chosen["unplaced_direction"])
328
+ unplaced.remove(unplaced_name)
329
+ placed_reads.append(
330
+ [
331
+ unplaced_name,
332
+ chosen["unplaced_start"],
333
+ chosen["unplaced_start"] + len(read_sequences[unplaced_name]),
334
+ "+" if chosen["unplaced_direction"] == 1 else "-",
335
+ len(placed_reads) + 1,
336
+ ]
337
+ )
338
+ progress = True
339
+
340
+ # 记账坐标是“以种子为原点”的全局坐标,左侧延伸后可能出现负值;
341
+ # 这里统一平移到最终 contig 的 0-based 坐标(只用于统计,不再写表)。
342
+ coordinate_shift = -contig_start
343
+ if coordinate_shift:
344
+ for row in placed_reads:
345
+ row[1] += coordinate_shift
346
+ row[2] += coordinate_shift
347
+ for row in placed_reads:
348
+ if row[1] < 0 or row[2] > len(contig_sequence):
349
+ raise RuntimeError(
350
+ "layout coordinate out of contig: {0} not in [0, {1}]".format(
351
+ (row[1], row[2]), len(contig_sequence)
352
+ )
353
+ )
354
+
355
+ contigs.append(
356
+ {
357
+ "name": "oe_contig{0}".format(len(contigs) + 1),
358
+ "sequence": contig_sequence,
359
+ "placed": placed_reads,
360
+ "n_reads": len(placed),
361
+ }
362
+ )
363
+
364
+ return contigs
365
+
366
+
367
+ ### 主流程 ###
368
+
369
+
370
+ @dataclass(slots=True)
371
+ class AssembleSettings:
372
+ """``alnkit long assemble`` 的全部入参(与旧脚本参数一一对应)。"""
373
+
374
+ classes_dir: str = ""
375
+ paf: str = ""
376
+ outdir: str = "."
377
+ min_overlap: int = 500
378
+ min_identity: float = 0.90
379
+ agreement_bp: int = 200
380
+
381
+
382
+ def run(settings: AssembleSettings) -> dict[str, object]:
383
+ os.makedirs(settings.outdir, exist_ok=True)
384
+
385
+ class_names = sorted(
386
+ name
387
+ for name in os.listdir(settings.classes_dir)
388
+ if os.path.isdir(os.path.join(settings.classes_dir, name))
389
+ )
390
+ all_contigs = {}
391
+ summary_rows = []
392
+
393
+ for class_name in class_names:
394
+ class_dir = os.path.join(settings.classes_dir, class_name)
395
+ read_sequences = read_fastq_sequences(os.path.join(class_dir, "reads.fastq"))
396
+ junction_read_names = read_junction_read_names(
397
+ os.path.join(class_dir, "junction_reads.fastq")
398
+ )
399
+ best_blocks = load_best_blocks(
400
+ settings.paf, set(read_sequences), settings.min_overlap, settings.min_identity
401
+ )
402
+ contigs = assemble_one_class(
403
+ read_sequences, junction_read_names, best_blocks, settings.agreement_bp
404
+ )
405
+
406
+ output_dir = os.path.join(settings.outdir, class_name, "overlap_extension")
407
+ os.makedirs(output_dir, exist_ok=True)
408
+
409
+ contig_records = {}
410
+ for contig in contigs:
411
+ contig_records[contig["name"]] = contig["sequence"]
412
+ all_contigs["{0}|{1}".format(class_name, contig["name"])] = contig["sequence"]
413
+
414
+ write_fasta_records(contig_records, os.path.join(output_dir, "contigs.fasta"))
415
+
416
+ placed_read_count = sum(contig["n_reads"] for contig in contigs)
417
+ total_bp = sum(len(contig["sequence"]) for contig in contigs)
418
+ longest_bp = max((len(contig["sequence"]) for contig in contigs), default=0)
419
+ write_tsv(
420
+ os.path.join(output_dir, "assembly_stats.tsv"),
421
+ [
422
+ "class_id",
423
+ "n_contigs",
424
+ "assembly_bp",
425
+ "longest_contig_bp",
426
+ ],
427
+ [[class_name, len(contigs), total_bp, longest_bp]],
428
+ )
429
+ summary_rows.append([class_name, len(contigs), total_bp, longest_bp])
430
+
431
+ print(
432
+ "{0}: contigs={1} total={2} bp longest={3} bp reads={4}".format(
433
+ class_name, len(contigs), total_bp, longest_bp, placed_read_count
434
+ )
435
+ )
436
+
437
+ write_fasta_records(all_contigs, os.path.join(settings.outdir, "all_overlap_contigs.fasta"))
438
+ write_tsv(
439
+ os.path.join(settings.outdir, "overlap_assembly_summary.tsv"),
440
+ [
441
+ "class_id",
442
+ "n_contigs",
443
+ "assembly_bp",
444
+ "longest_contig_bp",
445
+ ],
446
+ summary_rows,
447
+ )
448
+
449
+ return {
450
+ "outdir": settings.outdir,
451
+ "classes": len(class_names),
452
+ "contigs": len(all_contigs),
453
+ }