unipan 0.4.0__tar.gz
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.
Potentially problematic release.
This version of unipan might be problematic. Click here for more details.
- unipan-0.4.0/PKG-INFO +12 -0
- unipan-0.4.0/README.md +104 -0
- unipan-0.4.0/pyproject.toml +25 -0
- unipan-0.4.0/setup.cfg +4 -0
- unipan-0.4.0/tests/test_call.py +101 -0
- unipan-0.4.0/tests/test_chromrun.py +74 -0
- unipan-0.4.0/tests/test_chromstore.py +82 -0
- unipan-0.4.0/tests/test_events.py +96 -0
- unipan-0.4.0/tests/test_fast.py +59 -0
- unipan-0.4.0/tests/test_localalign.py +46 -0
- unipan-0.4.0/tests/test_m1.py +41 -0
- unipan-0.4.0/tests/test_m2_arrays.py +68 -0
- unipan-0.4.0/tests/test_m2_synthetic.py +61 -0
- unipan-0.4.0/tests/test_output.py +50 -0
- unipan-0.4.0/tests/test_profile.py +63 -0
- unipan-0.4.0/tests/test_refine_engine.py +53 -0
- unipan-0.4.0/tests/test_refine_fixture.py +105 -0
- unipan-0.4.0/tests/test_stream.py +63 -0
- unipan-0.4.0/tests/test_stream_parts.py +34 -0
- unipan-0.4.0/tests/test_truthset.py +73 -0
- unipan-0.4.0/unipan/__init__.py +12 -0
- unipan-0.4.0/unipan/_fast.py +191 -0
- unipan-0.4.0/unipan/chromrun.py +343 -0
- unipan-0.4.0/unipan/chromstore.py +217 -0
- unipan-0.4.0/unipan/cli.py +432 -0
- unipan-0.4.0/unipan/cli_compound_backup.py +169 -0
- unipan-0.4.0/unipan/cli_v04_backup.py +167 -0
- unipan-0.4.0/unipan/cli_v06_backup.py +169 -0
- unipan-0.4.0/unipan/cli_v07_backup.py +170 -0
- unipan-0.4.0/unipan/cli_v09a_backup.py +175 -0
- unipan-0.4.0/unipan/cli_v10_backup.py +177 -0
- unipan-0.4.0/unipan/cli_v11_backup.py +235 -0
- unipan-0.4.0/unipan/events.py +254 -0
- unipan-0.4.0/unipan/figures.py +320 -0
- unipan-0.4.0/unipan/io.py +53 -0
- unipan-0.4.0/unipan/localalign.py +202 -0
- unipan-0.4.0/unipan/m1.py +117 -0
- unipan-0.4.0/unipan/m1_v07_backup.py +88 -0
- unipan-0.4.0/unipan/m1_v08_backup.py +113 -0
- unipan-0.4.0/unipan/m2.py +723 -0
- unipan-0.4.0/unipan/m2_v04_backup.py +243 -0
- unipan-0.4.0/unipan/m2_v05_backup.py +293 -0
- unipan-0.4.0/unipan/m2_v06_backup.py +342 -0
- unipan-0.4.0/unipan/m2_v07_backup.py +394 -0
- unipan-0.4.0/unipan/m2_v08_backup.py +445 -0
- unipan-0.4.0/unipan/m2_v09_backup.py +455 -0
- unipan-0.4.0/unipan/m2_v10_run3_backup.py +463 -0
- unipan-0.4.0/unipan/output.py +280 -0
- unipan-0.4.0/unipan/refine.py +478 -0
- unipan-0.4.0/unipan/report.py +157 -0
- unipan-0.4.0/unipan/splitalign.py +99 -0
- unipan-0.4.0/unipan/stream.py +269 -0
- unipan-0.4.0/unipan/validate.py +366 -0
- unipan-0.4.0/unipan/validate_compound_backup.py +257 -0
- unipan-0.4.0/unipan/validate_v04_backup.py +210 -0
- unipan-0.4.0/unipan/validate_v05_backup.py +277 -0
- unipan-0.4.0/unipan/validate_v06_backup.py +280 -0
- unipan-0.4.0/unipan/validate_v08_backup.py +296 -0
- unipan-0.4.0/unipan/validate_v09a_backup.py +306 -0
- unipan-0.4.0/unipan/validate_v11_backup.py +348 -0
- unipan-0.4.0/unipan.egg-info/PKG-INFO +12 -0
- unipan-0.4.0/unipan.egg-info/SOURCES.txt +64 -0
- unipan-0.4.0/unipan.egg-info/dependency_links.txt +1 -0
- unipan-0.4.0/unipan.egg-info/entry_points.txt +2 -0
- unipan-0.4.0/unipan.egg-info/requires.txt +8 -0
- unipan-0.4.0/unipan.egg-info/top_level.txt +1 -0
unipan-0.4.0/PKG-INFO
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: unipan
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Summary: UniPan: independent pangenome analysis engine (Python prototype)
|
|
5
|
+
Author: Nagendra Pratap Singh
|
|
6
|
+
Requires-Python: >=3.9
|
|
7
|
+
Requires-Dist: numpy>=1.22
|
|
8
|
+
Requires-Dist: numba>=0.59
|
|
9
|
+
Provides-Extra: validate
|
|
10
|
+
Requires-Dist: mappy>=2.24; extra == "validate"
|
|
11
|
+
Provides-Extra: test
|
|
12
|
+
Requires-Dist: pytest; extra == "test"
|
unipan-0.4.0/README.md
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# UniPan
|
|
2
|
+
|
|
3
|
+
**Anchor-based pangenome structural-variant discovery with breakpoint refinement and read-level validation**
|
|
4
|
+
|
|
5
|
+
UniPan compares chromosome-scale genome assemblies against a reference and reports
|
|
6
|
+
syntenic blocks, indels, inversions, rearrangements and dispersed copies. It targets the
|
|
7
|
+
cases where current tools are weakest: inversions in divergent sequence and
|
|
8
|
+
repeat-mediated breakpoints.
|
|
9
|
+
|
|
10
|
+
> **Status: research prototype (v1.1, pre-release).** Interfaces and outputs may change.
|
|
11
|
+
> Results marked *preliminary* are under validation and should not be cited.
|
|
12
|
+
|
|
13
|
+
## How it works
|
|
14
|
+
|
|
15
|
+
| Module | What it does |
|
|
16
|
+
|---|---|
|
|
17
|
+
| M1 - anchors | Canonical (k=19, w=10) minimizers, invertible hash, compact NumPy index, Numba kernels |
|
|
18
|
+
| M2 - chaining | Single-copy anchors, bounded DP chaining with backtracking, orientation-agnostic fragment selection, dispersed-copy detection, junction typing, syntenic blocks |
|
|
19
|
+
| Breakpoint CI | Confidence interval in both genomes plus a repeat/unique context label per junction |
|
|
20
|
+
| Inversion events | Consecutive inverted fragments merged into one event (scripts/m2_inversion_events.py) |
|
|
21
|
+
| Breakpoint refinement | Local base-level re-alignment of each breakpoint window; reports repeat sequence at the junction (scripts/refine_breakpoints.py, prototype) |
|
|
22
|
+
|
|
23
|
+
## Installation
|
|
24
|
+
|
|
25
|
+
conda create -n unipan python=3.11 -y && conda activate unipan
|
|
26
|
+
git clone https://github.com/nps-genomics/UniPan.git && cd UniPan
|
|
27
|
+
pip install -e ".[validate,test]"
|
|
28
|
+
pytest -q
|
|
29
|
+
|
|
30
|
+
## Quick start
|
|
31
|
+
|
|
32
|
+
unipan run --fasta-dir data/genomes --reference Col-0 --out results/
|
|
33
|
+
unipan validate --fasta-dir data/genomes --reference Col-0 --out results/
|
|
34
|
+
python scripts/m2_inversion_events.py results/
|
|
35
|
+
python scripts/refine_breakpoints.py results/m2_inversion_events.tsv data/genomes Col-0 results/events_refined.tsv
|
|
36
|
+
|
|
37
|
+
One FASTA per genome in a folder; the genome ID is the file name stem.
|
|
38
|
+
|
|
39
|
+
### Main outputs
|
|
40
|
+
|
|
41
|
+
| File | Content |
|
|
42
|
+
|---|---|
|
|
43
|
+
| m2_fragments.tsv | Collinear fragments: coordinates in both genomes, orientation, anchors, status |
|
|
44
|
+
| m2_junctions.tsv | Junctions: type, size change, breakpoint CI, repeat context |
|
|
45
|
+
| m2_syntenic_blocks.tsv | Syntenic blocks |
|
|
46
|
+
| m2_inversion_events.tsv | One row per inversion event with outer breakpoint intervals |
|
|
47
|
+
| validation_vs_minimap2.tsv | Per-event recall / precision status |
|
|
48
|
+
|
|
49
|
+
## Validation framework
|
|
50
|
+
|
|
51
|
+
| Script | Purpose |
|
|
52
|
+
|---|---|
|
|
53
|
+
| unipan validate | Locus-based comparison with minimap2; chromosome-end and satellite masks; high-confidence subset |
|
|
54
|
+
| scripts/syri_at_events.py, scripts/compare_syri_local.py | Comparison with SyRI |
|
|
55
|
+
| scripts/compare_pggb.py, scripts/pggb_gfa_inversions.py | Comparison with PGGB |
|
|
56
|
+
| scripts/run_longread_validation.sh | Per accession: refinement, strand-switch read test with random control, SyRI labels |
|
|
57
|
+
| scripts/longread_event_test.py | Strand-switch ONT read test of inversion events (reads vs reference) |
|
|
58
|
+
| scripts/longread_inversion_check.py | Assembly integrity at breakpoints (reads vs own assembly) |
|
|
59
|
+
| scripts/record_result.py | Freezes a finished result into paper/ with a manifest (git commit, md5s, command) |
|
|
60
|
+
|
|
61
|
+
Raw ONT reads are filtered with fastplong (mean Q >= 7, length >= 1 kb) before mapping; untrimmed reads are never mapped.
|
|
62
|
+
|
|
63
|
+
## Results so far
|
|
64
|
+
|
|
65
|
+
Data: five *Arabidopsis thaliana* chromosome-level assemblies (Col-0 reference; Bla-1, Cvi-0,
|
|
66
|
+
Ita-0, KZ9) and ONT reads for Bla-1, Ita-0 and KZ9 (paper/data_sources.tsv). Every result is
|
|
67
|
+
tracked in paper/results_registry.tsv.
|
|
68
|
+
|
|
69
|
+
| Result | Status |
|
|
70
|
+
|---|---|
|
|
71
|
+
| Genome-wide vs minimap2: recall 92.9%, precision 95.3% (96.2% in high-confidence regions); 11 min, 7.5 GB for 5 genomes | final |
|
|
72
|
+
| KZ9, Bla-1, Ita-0: 19/23 testable UniPan-only inversions confirmed by strand-switching ONT reads vs 1/900 random controls; SyRI labels these loci HDR/NOTAL (minimap2 asm5 and asm20). 36/62 UniPan-only events not testable (breakpoint interval > 20 kb) | final |
|
|
73
|
+
| Breakpoint refinement: median breakpoint interval 40 to 17 kb (KZ9), 14 to 4.2 kb (Bla-1), 20 to 4.9 kb (Ita-0) | preliminary |
|
|
74
|
+
| Human pilot (HG002 vs CHM13, chr8): 3.9 Mb 8p23.1 inversion on one haplotype; 10 min, 11.5 GB | preliminary |
|
|
75
|
+
|
|
76
|
+
## Known limitations
|
|
77
|
+
|
|
78
|
+
- Breakpoints inside long repeats are placed only approximately; refinement is a prototype.
|
|
79
|
+
- Small isolated inversion calls far from any collinear anchor are often unreliable.
|
|
80
|
+
- Centromeric and satellite regions are not analysed (single-copy anchors only).
|
|
81
|
+
- Tested so far on *A. thaliana* (5 genomes) and one human chromosome.
|
|
82
|
+
|
|
83
|
+
## Repository layout
|
|
84
|
+
|
|
85
|
+
| Folder | Content |
|
|
86
|
+
|---|---|
|
|
87
|
+
| unipan/ | Core package (io, m1, m2, _fast, validate, cli) |
|
|
88
|
+
| tests/ | Unit and synthetic tests |
|
|
89
|
+
| scripts/ | Benchmarking, validation and analysis scripts |
|
|
90
|
+
| paper/ | Frozen results: results_registry.tsv, data_sources.tsv, main/, supp/ |
|
|
91
|
+
| grant/ | Preliminary-data material |
|
|
92
|
+
| ROADMAP.md | Design decisions and version log |
|
|
93
|
+
|
|
94
|
+
Raw data (assemblies, reads, BAMs) are not stored here; accessions and checksums are in
|
|
95
|
+
paper/data_sources.tsv and each result's MANIFEST.json.
|
|
96
|
+
|
|
97
|
+
## Authors
|
|
98
|
+
|
|
99
|
+
Nagendra Pratap Singh (developer), Dr. Amit Dhingra (PI), Texas A&M University
|
|
100
|
+
|
|
101
|
+
## Citation and license
|
|
102
|
+
|
|
103
|
+
Manuscript in preparation. License to be decided before public release. Please contact the
|
|
104
|
+
authors before using this code or its results.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "unipan"
|
|
7
|
+
version = "0.4.0"
|
|
8
|
+
description = "UniPan: independent pangenome analysis engine (Python prototype)"
|
|
9
|
+
authors = [{ name = "Nagendra Pratap Singh" }]
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
dependencies = ["numpy>=1.22", "numba>=0.59"]
|
|
12
|
+
|
|
13
|
+
[project.optional-dependencies]
|
|
14
|
+
validate = ["mappy>=2.24"]
|
|
15
|
+
test = ["pytest"]
|
|
16
|
+
|
|
17
|
+
[project.scripts]
|
|
18
|
+
unipan = "unipan.cli:main"
|
|
19
|
+
|
|
20
|
+
[tool.setuptools]
|
|
21
|
+
packages = ["unipan"]
|
|
22
|
+
|
|
23
|
+
[tool.pytest.ini_options]
|
|
24
|
+
testpaths = ["tests"]
|
|
25
|
+
norecursedirs = ["paper", "scripts", "data", "results*", "logs", ".git"]
|
unipan-0.4.0/setup.cfg
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""End-to-end `unipan call` on synthetic genomes with a known inversion."""
|
|
2
|
+
import gzip
|
|
3
|
+
import random
|
|
4
|
+
|
|
5
|
+
from unipan.cli import main
|
|
6
|
+
from unipan.io import read_tsv
|
|
7
|
+
|
|
8
|
+
COMP = str.maketrans("ACGT", "TGCA")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def rc(s):
|
|
12
|
+
return s.translate(COMP)[::-1]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def mutate(s, rate, rng):
|
|
16
|
+
b = list(s)
|
|
17
|
+
for i in range(len(b)):
|
|
18
|
+
if rng.random() < rate:
|
|
19
|
+
b[i] = rng.choice("ACGT".replace(b[i], ""))
|
|
20
|
+
return "".join(b)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_call_finds_inversion_with_refined_breakpoints(tmp_path):
|
|
24
|
+
rng = random.Random(7)
|
|
25
|
+
ref = "".join(rng.choice("ACGT") for _ in range(300_000))
|
|
26
|
+
lo, hi = 120_000, 150_000 # 30 kb inversion
|
|
27
|
+
tgt = mutate(ref[:lo] + rc(ref[lo:hi]) + ref[hi:], 0.01, rng)
|
|
28
|
+
other = mutate(ref, 0.01, rng) # collinear genome
|
|
29
|
+
d = tmp_path / "g"; d.mkdir()
|
|
30
|
+
for name, seq in (("R", ref), ("T", tgt), ("C", other)):
|
|
31
|
+
(d / f"{name}.fa").write_text(f">chr1\n{seq}\n")
|
|
32
|
+
out = tmp_path / "out"
|
|
33
|
+
main(["call", "--fasta-dir", str(d), "--reference", "R", "--out", str(out)])
|
|
34
|
+
E = [e for e in read_tsv(out / "calls" / "inversion_events.tsv") if e["target"] == "T"]
|
|
35
|
+
assert len(E) == 1
|
|
36
|
+
e = E[0]
|
|
37
|
+
assert e["tier"] == "HIGH" and e["refine_status"] == "REFINED"
|
|
38
|
+
assert abs(int(e["refined_left_hi"]) - lo) <= 100 and abs(int(e["refined_right_lo"]) - hi) <= 100
|
|
39
|
+
assert not [x for x in read_tsv(out / "calls" / "inversion_events.tsv") if x["target"] == "C"]
|
|
40
|
+
rec = [l for l in gzip.open(out / "calls" / "inversions.multisample.vcf.gz", "rt") if not l.startswith("#")]
|
|
41
|
+
assert len(rec) == 1
|
|
42
|
+
assert rec[0].rstrip("\n").split("\t")[9:] == ["0:.", "0:.", "1:HIGH"] # samples R, C, T
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_threads_give_identical_output(tmp_path):
|
|
46
|
+
rng = random.Random(11)
|
|
47
|
+
ref = "".join(rng.choice("ACGT") for _ in range(200_000))
|
|
48
|
+
d = tmp_path / "g"; d.mkdir()
|
|
49
|
+
(d / "R.fa").write_text(f">chr1\n{ref}\n")
|
|
50
|
+
for i, (lo, hi) in enumerate(((50_000, 70_000), (100_000, 115_000), (150_000, 170_000))):
|
|
51
|
+
tgt = mutate(ref[:lo] + rc(ref[lo:hi]) + ref[hi:], 0.01, rng)
|
|
52
|
+
(d / f"T{i}.fa").write_text(f">chr1\n{tgt}\n")
|
|
53
|
+
outs = []
|
|
54
|
+
for th in (1, 3):
|
|
55
|
+
out = tmp_path / f"o{th}"
|
|
56
|
+
main(["call", "--fasta-dir", str(d), "--reference", "R", "--out", str(out),
|
|
57
|
+
"--threads", str(th)])
|
|
58
|
+
outs.append(out)
|
|
59
|
+
for f in ("intermediate/m2_fragments.tsv", "intermediate/m2_junctions.tsv", "intermediate/m2_syntenic_blocks.tsv",
|
|
60
|
+
"calls/inversion_events.tsv", "calls/inversions.vcf.gz", "calls/inversions.multisample.vcf.gz",
|
|
61
|
+
"calls/inversions.vcf.gz.tbi", "summary.tsv", "intermediate/breakpoint_hits.tsv.gz"):
|
|
62
|
+
assert (outs[0] / f).read_bytes() == (outs[1] / f).read_bytes(), f
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_result_folder_layout(tmp_path):
|
|
66
|
+
rng = random.Random(3)
|
|
67
|
+
ref = "".join(rng.choice("ACGT") for _ in range(200_000))
|
|
68
|
+
d = tmp_path / "g"; d.mkdir()
|
|
69
|
+
(d / "R.fa").write_text(f">chr1\n{ref}\n")
|
|
70
|
+
tgt = mutate(ref[:60_000] + rc(ref[60_000:90_000]) + ref[90_000:], 0.01, rng)
|
|
71
|
+
(d / "T.fa").write_text(f">chr1\n{tgt}\n")
|
|
72
|
+
out = tmp_path / "out"
|
|
73
|
+
main(["call", "--fasta-dir", str(d), "--reference", "R", "--out", str(out)])
|
|
74
|
+
for f in ("README.txt", "summary.tsv", "calls/inversions.vcf.gz", "calls/inversions.vcf.gz.tbi",
|
|
75
|
+
"calls/inversions.multisample.vcf.gz.tbi", "calls/inversions.bed", "calls/inversion_events.high.tsv",
|
|
76
|
+
"qc/params.json", "qc/timing.json", "intermediate/m2_fragments.tsv"):
|
|
77
|
+
assert (out / f).exists(), f
|
|
78
|
+
assert not (out / "calls" / "inversions.vcf").exists() # plain VCF replaced by .vcf.gz
|
|
79
|
+
s = read_tsv(out / "summary.tsv")
|
|
80
|
+
assert [r["genome"] for r in s] == ["T"] and s[0]["events"] == "1" and s[0]["HIGH"] == "1"
|
|
81
|
+
h = read_tsv(out / "calls" / "inversion_events.high.tsv")
|
|
82
|
+
assert len(h) == 1 and abs(int(h[0]["start"]) - 60_001) <= 2000
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_figures_and_report(tmp_path):
|
|
86
|
+
pytest = __import__("pytest")
|
|
87
|
+
pytest.importorskip("matplotlib")
|
|
88
|
+
rng = random.Random(4)
|
|
89
|
+
ref = "".join(rng.choice("ACGT") for _ in range(200_000))
|
|
90
|
+
d = tmp_path / "g"; d.mkdir()
|
|
91
|
+
(d / "R.fa").write_text(f">chr1\n{ref}\n")
|
|
92
|
+
(d / "T.fa").write_text(f">chr1\n{mutate(ref[:50_000] + rc(ref[50_000:80_000]) + ref[80_000:], 0.01, rng)}\n")
|
|
93
|
+
out = tmp_path / "out"
|
|
94
|
+
main(["call", "--fasta-dir", str(d), "--reference", "R", "--out", str(out), "--no-plots"])
|
|
95
|
+
assert not list((out / "figures").iterdir())
|
|
96
|
+
main(["report", str(out)])
|
|
97
|
+
for n in ("genome_overview", "size_distribution", "genotype_matrix", "breakpoints"):
|
|
98
|
+
assert (out / "figures" / f"{n}.png").stat().st_size > 1000 and (out / "figures" / f"{n}.pdf").exists()
|
|
99
|
+
assert "figures/" in (out / "README.txt").read_text()
|
|
100
|
+
e = read_tsv(out / "calls" / "inversion_events.tsv")[0]
|
|
101
|
+
assert e["event_id"] == "UNIPAN_INV_1" and (out / "figures" / "events" / "UNIPAN_INV_1.png").stat().st_size > 1000
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""--per-chromosome must give byte-identical results to whole-genome streaming, including repeats
|
|
2
|
+
shared between chromosomes (genome-wide single-copy rule), a translocated segment, a target chromosome
|
|
3
|
+
with a different name, an unrelated target, several workers, and refinement."""
|
|
4
|
+
import random
|
|
5
|
+
|
|
6
|
+
from unipan.cli import main
|
|
7
|
+
|
|
8
|
+
COMP = str.maketrans("ACGT", "TGCA")
|
|
9
|
+
FILES = ("intermediate/m2_fragments.tsv", "intermediate/m2_junctions.tsv",
|
|
10
|
+
"intermediate/m2_syntenic_blocks.tsv", "intermediate/breakpoint_hits.tsv.gz",
|
|
11
|
+
"calls/inversion_events.tsv", "calls/inversion_events.high.tsv", "calls/inversions.vcf.gz",
|
|
12
|
+
"calls/inversions.multisample.vcf.gz", "calls/inversions.bed", "summary.tsv")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def rc(s):
|
|
16
|
+
return s.translate(COMP)[::-1]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def mutate(s, rate, rng):
|
|
20
|
+
b = list(s)
|
|
21
|
+
for i in range(len(b)):
|
|
22
|
+
if rng.random() < rate:
|
|
23
|
+
b[i] = rng.choice("ACGT".replace(b[i], ""))
|
|
24
|
+
return "".join(b)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _genomes(d):
|
|
28
|
+
rng = random.Random(31)
|
|
29
|
+
R = lambda n: "".join(rng.choice("ACGT") for _ in range(n))
|
|
30
|
+
rep = R(4_000) # on chr1 and chr2: not single-copy
|
|
31
|
+
c1 = R(80_000) + rep + R(70_000)
|
|
32
|
+
c2 = R(50_000) + rep + R(60_000)
|
|
33
|
+
c3 = R(90_000)
|
|
34
|
+
(d / "R.fa").write_text(f">chr1\n{c1}\n>chr2\n{c2}\n>chr3\n{c3}\n")
|
|
35
|
+
t1 = c1[:40_000] + rc(c1[40_000:65_000]) + c1[65_000:] # inversion on chr1
|
|
36
|
+
(d / "A.fa").write_text(f">a1\n{mutate(t1, 0.01, rng)}\n>a2\n{mutate(c2, 0.01, rng)}\n>a3\n{mutate(c3, 0.01, rng)}\n")
|
|
37
|
+
t2 = c2[:20_000] + rc(c2[20_000:45_000]) + c2[45_000:] # inversion on chr2
|
|
38
|
+
t3 = c3[:30_000] + c1[100_000:130_000] + c3[30_000:] # chr1 segment inserted into chr3
|
|
39
|
+
(d / "B.fa").write_text(f">chr2\n{mutate(t2, 0.01, rng)}\n>chr3\n{mutate(t3, 0.01, rng)}\n>chr1\n{mutate(c1, 0.01, rng)}\n")
|
|
40
|
+
(d / "Z.fa").write_text(">u\n" + R(30_000) + "\n") # unrelated
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_per_chromosome_identical(tmp_path):
|
|
44
|
+
d = tmp_path / "g"; d.mkdir(); _genomes(d)
|
|
45
|
+
runs = {}
|
|
46
|
+
for tag, extra in (("whole", ["--threads", "1"]),
|
|
47
|
+
("mem1", ["--threads", "1", "--per-chromosome", "--store", "memory"]),
|
|
48
|
+
("mem3", ["--threads", "3", "--per-chromosome", "--store", "memory"]),
|
|
49
|
+
("disk1", ["--threads", "1", "--per-chromosome", "--store", "disk"]),
|
|
50
|
+
("disk3", ["--threads", "3", "--per-chromosome", "--store", "disk"]),
|
|
51
|
+
("auto3", ["--threads", "3", "--per-chromosome"]),
|
|
52
|
+
("disk3w1", ["--threads", "3", "--per-chromosome", "--store", "disk", "--disk-writers", "1"])):
|
|
53
|
+
out = tmp_path / tag
|
|
54
|
+
main(["call", "--fasta-dir", str(d), "--reference", "R", "--out", str(out), "--no-plots"] + extra)
|
|
55
|
+
runs[tag] = out
|
|
56
|
+
assert (runs["whole"] / "calls" / "inversion_events.tsv").read_text().count("\n") >= 3
|
|
57
|
+
for tag in ("mem1", "mem3", "disk1", "disk3", "auto3", "disk3w1"):
|
|
58
|
+
assert not (runs[tag] / "intermediate" / "stores").exists()
|
|
59
|
+
for f in FILES:
|
|
60
|
+
assert (runs["whole"] / f).read_bytes() == (runs[tag] / f).read_bytes(), (tag, f)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_store_mode_plan(tmp_path):
|
|
64
|
+
from unipan.chromrun import plan_workers_chrom
|
|
65
|
+
files = {}
|
|
66
|
+
for g in ("R", "A", "B", "C"):
|
|
67
|
+
files[g] = tmp_path / f"{g}.fa"
|
|
68
|
+
files[g].write_text(">c\n" + "A" * 1_000_000 + "\n")
|
|
69
|
+
n, plan = plan_workers_chrom(4, files, "R", max_memory_gb=100)
|
|
70
|
+
assert plan["store"] == "memory" and n == 3
|
|
71
|
+
n, plan = plan_workers_chrom(4, files, "R", max_memory_gb=0.5) # nothing fits: disk, 1 worker
|
|
72
|
+
assert plan["store"] == "disk" and n == 1
|
|
73
|
+
n, plan = plan_workers_chrom(4, files, "R", max_memory_gb=100, store="disk")
|
|
74
|
+
assert plan["store"] == "disk"
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Per-chromosome store: minimizers identical to index_genomes, counts identical to the
|
|
2
|
+
genome-wide copy_counts32, for any number of buckets (incl. repeats shared across chromosomes,
|
|
3
|
+
a chromosome shorter than k, and gzip input)."""
|
|
4
|
+
import gzip
|
|
5
|
+
import random
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from unipan.chromstore import build_store, build_store_memory, chrom_arrays, chrom_seq, iter_fasta, open_store
|
|
11
|
+
from unipan.io import read_fasta
|
|
12
|
+
from unipan.m1 import index_genomes
|
|
13
|
+
from unipan.m2 import copy_counts32, target_profile
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _fasta(d, gz=False):
|
|
17
|
+
rng = random.Random(4)
|
|
18
|
+
rep = "".join(rng.choice("ACGT") for _ in range(2_000)) # copy on chr1, chr2, chr3
|
|
19
|
+
rep2 = "".join(rng.choice("ACGT") for _ in range(700)) # twice on chr2
|
|
20
|
+
chrs = {
|
|
21
|
+
"chr1": "".join(rng.choice("ACGT") for _ in range(60_000)) + rep + "".join(rng.choice("ACGT") for _ in range(30_000)),
|
|
22
|
+
"chr2": rep2 + "".join(rng.choice("acgt") for _ in range(40_000)) + rep + rep2,
|
|
23
|
+
"tiny": "ACGTA", # shorter than k
|
|
24
|
+
"chr3": "".join(rng.choice("ACGT") for _ in range(25_000)) + rep + "NNNNN" + "".join(rng.choice("ACGT") for _ in range(9_000)),
|
|
25
|
+
}
|
|
26
|
+
txt = "".join(f">{n} description\n" + "\n".join(s[i:i + 61] for i in range(0, len(s), 61)) + "\n" for n, s in chrs.items())
|
|
27
|
+
p = d / ("g.fa.gz" if gz else "g.fa")
|
|
28
|
+
if gz:
|
|
29
|
+
with gzip.open(p, "wt") as f:
|
|
30
|
+
f.write(txt)
|
|
31
|
+
else:
|
|
32
|
+
p.write_text(txt)
|
|
33
|
+
return p
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@pytest.mark.parametrize("buckets", [1, 3, 7])
|
|
37
|
+
def test_store_equals_in_memory(tmp_path, buckets):
|
|
38
|
+
fa = _fasta(tmp_path)
|
|
39
|
+
man = build_store(fa, tmp_path / "store", buckets=buckets)
|
|
40
|
+
genome = read_fasta(fa)
|
|
41
|
+
idx = index_genomes({"g": genome})["g"]
|
|
42
|
+
prof = target_profile(idx)
|
|
43
|
+
assert [c["name"] for c in man["chromosomes"]] == list(genome)
|
|
44
|
+
assert not (tmp_path / "store" / "buckets").exists()
|
|
45
|
+
for c in man["chromosomes"]:
|
|
46
|
+
i, name = c["index"], c["name"]
|
|
47
|
+
pos, code, strand, cnt = chrom_arrays(tmp_path / "store", i)
|
|
48
|
+
assert np.array_equal(pos, idx[name][0]) and np.array_equal(code, idx[name][1])
|
|
49
|
+
assert np.array_equal(strand, idx[name][2])
|
|
50
|
+
a, b = int(prof["off"][i]), int(prof["off"][i + 1])
|
|
51
|
+
assert np.array_equal(cnt, prof["cnt"][a:b]), (name, buckets)
|
|
52
|
+
assert c["length"] == len(genome[name])
|
|
53
|
+
if c["length"]:
|
|
54
|
+
assert bytes(chrom_seq(tmp_path / "store", i)) == genome[name].encode()
|
|
55
|
+
assert (prof["cnt"] > 1).any() # the test really has repeats
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_gzip_and_iter_fasta(tmp_path):
|
|
59
|
+
fa = _fasta(tmp_path, gz=True)
|
|
60
|
+
assert dict(iter_fasta(fa)) == read_fasta(fa)
|
|
61
|
+
man = build_store(fa, tmp_path / "s", max_memory_gb=0.000001) # forces many buckets
|
|
62
|
+
from unipan.chromstore import max_buckets
|
|
63
|
+
assert 1 < man["buckets"] <= max_buckets() and open_store(tmp_path / "s")["buckets"] == man["buckets"]
|
|
64
|
+
idx = index_genomes({"g": read_fasta(fa)})["g"]
|
|
65
|
+
full = copy_counts32(np.concatenate([idx[n][1] for n in idx]))
|
|
66
|
+
got = np.concatenate([chrom_arrays(tmp_path / "s", c["index"])[3] for c in man["chromosomes"]])
|
|
67
|
+
assert np.array_equal(got, full)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@pytest.mark.parametrize("gz", [False, True])
|
|
71
|
+
def test_memory_store_equals_disk_store(tmp_path, gz):
|
|
72
|
+
fa = _fasta(tmp_path, gz=gz)
|
|
73
|
+
man = build_store(fa, tmp_path / "store", buckets=3)
|
|
74
|
+
ms = build_store_memory(fa)
|
|
75
|
+
assert open_store(ms)["chromosomes"] == man["chromosomes"]
|
|
76
|
+
assert open_store(ms)["repeated_codes"] == man["repeated_codes"]
|
|
77
|
+
for c in man["chromosomes"]:
|
|
78
|
+
i = c["index"]
|
|
79
|
+
for a, b in zip(chrom_arrays(tmp_path / "store", i), chrom_arrays(ms, i)):
|
|
80
|
+
assert a.dtype == b.dtype and np.array_equal(a, b)
|
|
81
|
+
if c["length"]:
|
|
82
|
+
assert bytes(chrom_seq(tmp_path / "store", i)) == bytes(chrom_seq(ms, i))
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
from unipan.events import events_from_fragments, tier, write_vcf, write_bed
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def frag(rs, re, ts, te, o, n, status="primary"):
|
|
5
|
+
return dict(reference="R", target="T", ref_chr="c1", target_chr="t1", status=status,
|
|
6
|
+
ref_start=rs, ref_end=re, target_start=ts, target_end=te, orientation=o, anchors=n)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_inversion_split_in_three_fragments_is_one_event():
|
|
10
|
+
F = [frag(0, 100_000, 0, 100_000, "+", 20_000),
|
|
11
|
+
frag(100_500, 140_000, 159_000, 199_500, "-", 6_000), # inverted, split by repeats
|
|
12
|
+
frag(141_000, 170_000, 129_000, 158_000, "-", 5_000),
|
|
13
|
+
frag(171_000, 199_000, 100_500, 128_500, "-", 5_000),
|
|
14
|
+
frag(200_000, 300_000, 200_000, 300_000, "+", 20_000)]
|
|
15
|
+
E = events_from_fragments(F)
|
|
16
|
+
assert len(E) == 1
|
|
17
|
+
e = E[0]
|
|
18
|
+
assert (e["ref_start"], e["ref_end"], e["n_fragments"]) == (100_500, 199_000, 3)
|
|
19
|
+
assert (e["left_ci"], e["right_ci"]) == (500, 1_000)
|
|
20
|
+
assert e["tier"] == "HIGH"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_tiers():
|
|
24
|
+
base = dict(left_ci=100, right_ci=100, anchors_per_kb=90)
|
|
25
|
+
assert tier(base) == "HIGH"
|
|
26
|
+
assert tier(dict(base, anchors_per_kb=16)) == "LOW_SUPPORT"
|
|
27
|
+
assert tier(dict(base, right_ci=60_000)) == "MEDIUM"
|
|
28
|
+
assert tier(dict(base, left_ci=2_000_000, anchors_per_kb=5)) == "ISOLATED"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_reverse_background_and_secondary_ignored():
|
|
32
|
+
F = [frag(0, 100_000, 300_000, 400_000, "-", 20_000),
|
|
33
|
+
frag(101_000, 150_000, 250_000, 299_000, "-", 9_000),
|
|
34
|
+
frag(151_000, 160_000, 200_000, 209_000, "+", 1_500), # inversion against a '-' background
|
|
35
|
+
frag(161_000, 260_000, 100_000, 199_000, "-", 20_000),
|
|
36
|
+
frag(10_000, 20_000, 5_000, 15_000, "+", 900, status="secondary")]
|
|
37
|
+
E = events_from_fragments(F)
|
|
38
|
+
assert len(E) == 1 and E[0]["background"] == "-" and E[0]["ref_start"] == 151_000
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_vcf_and_bed(tmp_path):
|
|
42
|
+
F = [frag(0, 100_000, 0, 100_000, "+", 20_000), frag(100_500, 140_000, 159_000, 199_500, "-", 6_000),
|
|
43
|
+
frag(141_000, 300_000, 200_000, 359_000, "+", 30_000)]
|
|
44
|
+
E = events_from_fragments(F)
|
|
45
|
+
write_vcf(tmp_path / "x.vcf", E, {"c1": 300_000})
|
|
46
|
+
lines = [l for l in open(tmp_path / "x.vcf") if not l.startswith("#")]
|
|
47
|
+
f = lines[0].split("\t")
|
|
48
|
+
assert f[0] == "c1" and f[1] == "100501" and f[4] == "<INV>" and f[6] == "PASS"
|
|
49
|
+
assert "SVTYPE=INV;END=140000;" in f[7] and "CIPOS=-500,0" in f[7] and "CIEND=0,1000" in f[7]
|
|
50
|
+
write_bed(tmp_path / "x.bed", E)
|
|
51
|
+
assert open(tmp_path / "x.bed").read().startswith("c1\t100500\t140000\tINV|T|HIGH")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_multisample_genotypes(tmp_path):
|
|
55
|
+
from unipan.events import merge_events, write_multisample_vcf
|
|
56
|
+
|
|
57
|
+
def fr(t, rs, re, ts, te, o, n):
|
|
58
|
+
return dict(reference="R", target=t, ref_chr="c1", target_chr="t1", status="primary",
|
|
59
|
+
ref_start=rs, ref_end=re, target_start=ts, target_end=te, orientation=o, anchors=n)
|
|
60
|
+
F = []
|
|
61
|
+
for t in ("A", "B"): # A and B carry the inversion
|
|
62
|
+
F += [fr(t, 0, 100_000, 0, 100_000, "+", 20_000), fr(t, 100_500, 140_000, 159_000, 199_500, "-", 6_000),
|
|
63
|
+
fr(t, 141_000, 300_000, 200_000, 359_000, "+", 30_000)]
|
|
64
|
+
F += [fr("C", 0, 300_000, 0, 300_000, "+", 60_000)] # C collinear across the locus
|
|
65
|
+
F += [fr("D", 0, 90_000, 0, 90_000, "+", 18_000), # D: locus not covered -> unresolved
|
|
66
|
+
fr("D", 160_000, 300_000, 160_000, 300_000, "+", 28_000)]
|
|
67
|
+
E = events_from_fragments(F)
|
|
68
|
+
loci, samples = merge_events(E, F)
|
|
69
|
+
assert len(loci) == 1 and samples == ["R", "A", "B", "C", "D"]
|
|
70
|
+
assert [loci[0]["gt"][s][0] for s in samples] == ["0", "1", "1", "0", "."]
|
|
71
|
+
write_multisample_vcf(tmp_path / "m.vcf", loci, samples)
|
|
72
|
+
rec = [l for l in open(tmp_path / "m.vcf") if not l.startswith("#")][0].rstrip("\n").split("\t")
|
|
73
|
+
assert "AC=2;AN=4;NMISS=1" in rec[7] and rec[8] == "GT:TR"
|
|
74
|
+
assert rec[9:] == ["0:.", "1:HIGH", "1:HIGH", "0:.", ".:."]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_collinear_split_fragments_genotype_zero():
|
|
78
|
+
from unipan.events import merge_events
|
|
79
|
+
|
|
80
|
+
def fr(t, rs, re, ts, te, o, n):
|
|
81
|
+
return dict(reference="R", target=t, ref_chr="c1", target_chr="t1", status="primary",
|
|
82
|
+
ref_start=rs, ref_end=re, target_start=ts, target_end=te, orientation=o, anchors=n)
|
|
83
|
+
F = [fr("A", 0, 100_000, 0, 100_000, "+", 20_000), fr("A", 100_500, 140_000, 159_000, 199_500, "-", 6_000),
|
|
84
|
+
fr("A", 141_000, 300_000, 200_000, 359_000, "+", 30_000),
|
|
85
|
+
fr("E", 0, 115_000, 0, 115_000, "+", 23_000), # collinear, split by an indel inside the locus
|
|
86
|
+
fr("E", 116_000, 300_000, 116_200, 300_200, "+", 37_000)]
|
|
87
|
+
loci, samples = merge_events(events_from_fragments(F), F)
|
|
88
|
+
assert [loci[0]["gt"][s][0] for s in samples] == ["0", "1", "0"]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_refine_size_check():
|
|
92
|
+
from unipan.refine import size_check
|
|
93
|
+
assert size_check(395, 6027) == "CORE" # small inverted core in a divergent block
|
|
94
|
+
assert size_check(166_000, 2_600) == "SUSPECT" # paralog jump
|
|
95
|
+
assert size_check(-19_000, 11_600) == "OVERLAP" # junctions inside inverted repeat
|
|
96
|
+
assert size_check(13_800, 13_827) == "REFINED"
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""The compiled kernels must reproduce the pure-Python reference exactly."""
|
|
2
|
+
import random
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from unipan import _fast
|
|
7
|
+
from unipan.m1 import minimizers
|
|
8
|
+
from unipan.m2 import _dp_chains_py as dp_chains
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _rand_seq(n, seed, n_runs=3):
|
|
12
|
+
rng = random.Random(seed)
|
|
13
|
+
s = [rng.choice("ACGT") for _ in range(n)]
|
|
14
|
+
for _ in range(n_runs):
|
|
15
|
+
a = rng.randrange(0, n - 60)
|
|
16
|
+
for i in range(a, a + rng.randrange(1, 60)):
|
|
17
|
+
s[i] = "N"
|
|
18
|
+
for _ in range(5):
|
|
19
|
+
a = rng.randrange(0, n - 300)
|
|
20
|
+
unit = "".join(rng.choice("ACGT") for _ in range(rng.randrange(1, 8)))
|
|
21
|
+
s[a:a + 250] = (unit * 300)[:250]
|
|
22
|
+
return "".join(s)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@pytest.mark.parametrize("seed", range(6))
|
|
26
|
+
@pytest.mark.parametrize("k,w", [(19, 10), (15, 5), (21, 11)])
|
|
27
|
+
def test_minimizers_kernel_matches_python(seed, k, w):
|
|
28
|
+
seq = _rand_seq(20_000, seed)
|
|
29
|
+
ref = minimizers(seq, k, w)
|
|
30
|
+
pos, code, strand = _fast.minimizers_arrays(seq, k, w)
|
|
31
|
+
fast = list(zip(pos.tolist(), code.tolist(),
|
|
32
|
+
["+" if s > 0 else "-" for s in strand.tolist()]))
|
|
33
|
+
assert fast == ref
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _rand_anchors(n, seed, orient):
|
|
37
|
+
rng = random.Random(seed)
|
|
38
|
+
pts, x, y = set(), 0, 50_000 if orient == "-" else 0
|
|
39
|
+
for _ in range(n):
|
|
40
|
+
x += rng.randrange(1, 40)
|
|
41
|
+
step = rng.randrange(1, 40)
|
|
42
|
+
if rng.random() < 0.02:
|
|
43
|
+
step += rng.randrange(-30, 600)
|
|
44
|
+
y = y + step if orient == "+" else y - step
|
|
45
|
+
pts.add((x, y))
|
|
46
|
+
if rng.random() < 0.05:
|
|
47
|
+
pts.add((x + rng.randrange(1, 5), rng.randrange(0, 60_000)))
|
|
48
|
+
return list(pts)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@pytest.mark.parametrize("seed", range(5))
|
|
52
|
+
@pytest.mark.parametrize("orient", ["+", "-"])
|
|
53
|
+
def test_dp_kernel_matches_python(seed, orient):
|
|
54
|
+
anchors = _rand_anchors(3000, seed, orient)
|
|
55
|
+
random.Random(seed).shuffle(anchors)
|
|
56
|
+
for max_prev, max_jump in [(256, 4000), (16, 300)]:
|
|
57
|
+
ref = dp_chains(anchors, orient, max_prev, max_jump)
|
|
58
|
+
fast = _fast.dp_chains_fast(anchors, orient, max_prev, max_jump)
|
|
59
|
+
assert fast == ref
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""UniPan's own local aligner (breakpoint refinement without minimap2)."""
|
|
2
|
+
import random
|
|
3
|
+
|
|
4
|
+
from unipan.localalign import local_hits
|
|
5
|
+
|
|
6
|
+
COMP = str.maketrans("ACGT", "TGCA")
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def rc(s):
|
|
10
|
+
return s.translate(COMP)[::-1]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def mutate(s, rate, rng):
|
|
14
|
+
b = list(s)
|
|
15
|
+
for i in range(len(b)):
|
|
16
|
+
if rng.random() < rate:
|
|
17
|
+
b[i] = rng.choice("ACGT".replace(b[i], ""))
|
|
18
|
+
return "".join(b)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_repeat_mediated_inversion_breakpoint():
|
|
22
|
+
"""3 kb inverted repeat at the junctions, 3 % divergence: the forward hit must end
|
|
23
|
+
at the junction and the reverse hit must start at the inverted-repeat copy, so the
|
|
24
|
+
overlap equals the repeat length (what refinement reports as BPREPEAT)."""
|
|
25
|
+
g = random.Random(3)
|
|
26
|
+
ref = "".join(g.choice("ACGT") for _ in range(400_000))
|
|
27
|
+
irep = "".join(g.choice("ACGT") for _ in range(3000))
|
|
28
|
+
lo, hi = 150_000, 200_000
|
|
29
|
+
ref = ref[:lo - 3000] + irep + ref[lo:hi] + rc(irep) + ref[hi + 3000:]
|
|
30
|
+
tgt = mutate(ref[:lo] + rc(ref[lo:hi]) + ref[hi:], 0.03, g)
|
|
31
|
+
w0 = lo - 20_000
|
|
32
|
+
H = sorted(local_hits(ref[w0:lo + 20_000], tgt, 0))
|
|
33
|
+
fwd = [h for h in H if h.strand == 1]
|
|
34
|
+
rev = [h for h in H if h.strand == -1]
|
|
35
|
+
assert len(fwd) == 1 and len(rev) == 1
|
|
36
|
+
assert abs(w0 + fwd[0].q_en - lo) <= 5 # junction
|
|
37
|
+
assert abs(w0 + rev[0].q_st - (lo - 3000)) <= 5 # inverted-repeat copy
|
|
38
|
+
assert abs(rev[0].r_en - (hi + 3000)) <= 5 # reverse hit lands on the far junction
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_no_spurious_inverted_hit_in_collinear_sequence():
|
|
42
|
+
g = random.Random(5)
|
|
43
|
+
ref = "".join(g.choice("ACGT") for _ in range(120_000))
|
|
44
|
+
tgt = mutate(ref, 0.02, g)
|
|
45
|
+
H = local_hits(ref[40_000:80_000], tgt, 0)
|
|
46
|
+
assert H and all(h.strand == 1 for h in H)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import random
|
|
2
|
+
|
|
3
|
+
from unipan.m1 import canonical_kmers, minimizers
|
|
4
|
+
|
|
5
|
+
COMP = str.maketrans("ACGT", "TGCA")
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def revcomp(s):
|
|
9
|
+
return s.translate(COMP)[::-1]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_canonical_codes_are_strand_independent():
|
|
13
|
+
random.seed(1)
|
|
14
|
+
s = "".join(random.choice("ACGT") for _ in range(500))
|
|
15
|
+
fwd = {code for _, code, _ in canonical_kmers(s, 19)}
|
|
16
|
+
rev = {code for _, code, _ in canonical_kmers(revcomp(s), 19)}
|
|
17
|
+
assert fwd == rev
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_reverse_complement_flips_strand_and_mirrors_position():
|
|
21
|
+
random.seed(2)
|
|
22
|
+
s = "".join(random.choice("ACGT") for _ in range(300))
|
|
23
|
+
n, k = len(s), 19
|
|
24
|
+
f = {code: (p, st) for p, code, st in canonical_kmers(s, k)}
|
|
25
|
+
for p, code, st in canonical_kmers(revcomp(s), k):
|
|
26
|
+
fp, fst = f[code]
|
|
27
|
+
assert fp == n - k - p
|
|
28
|
+
assert fst != st
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_minimizers_skip_n_and_cover_sequence():
|
|
32
|
+
random.seed(3)
|
|
33
|
+
s = "".join(random.choice("ACGT") for _ in range(2000))
|
|
34
|
+
s = s[:1000] + "N" * 50 + s[1050:]
|
|
35
|
+
mins = minimizers(s, 19, 10)
|
|
36
|
+
pos = [p for p, _, _ in mins]
|
|
37
|
+
assert pos == sorted(pos)
|
|
38
|
+
assert not any(980 < p < 1050 for p in pos) # no k-mer spans the Ns
|
|
39
|
+
gaps = [b - a for a, b in zip(pos, pos[1:]) if b < 950 or a > 1050]
|
|
40
|
+
assert max(gaps) <= 10 # window guarantee
|
|
41
|
+
assert 1 / 6.5 < len(mins) / len(s) < 1 / 4.5 # density ~ 2/(w+1)
|