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.

Files changed (66) hide show
  1. unipan-0.4.0/PKG-INFO +12 -0
  2. unipan-0.4.0/README.md +104 -0
  3. unipan-0.4.0/pyproject.toml +25 -0
  4. unipan-0.4.0/setup.cfg +4 -0
  5. unipan-0.4.0/tests/test_call.py +101 -0
  6. unipan-0.4.0/tests/test_chromrun.py +74 -0
  7. unipan-0.4.0/tests/test_chromstore.py +82 -0
  8. unipan-0.4.0/tests/test_events.py +96 -0
  9. unipan-0.4.0/tests/test_fast.py +59 -0
  10. unipan-0.4.0/tests/test_localalign.py +46 -0
  11. unipan-0.4.0/tests/test_m1.py +41 -0
  12. unipan-0.4.0/tests/test_m2_arrays.py +68 -0
  13. unipan-0.4.0/tests/test_m2_synthetic.py +61 -0
  14. unipan-0.4.0/tests/test_output.py +50 -0
  15. unipan-0.4.0/tests/test_profile.py +63 -0
  16. unipan-0.4.0/tests/test_refine_engine.py +53 -0
  17. unipan-0.4.0/tests/test_refine_fixture.py +105 -0
  18. unipan-0.4.0/tests/test_stream.py +63 -0
  19. unipan-0.4.0/tests/test_stream_parts.py +34 -0
  20. unipan-0.4.0/tests/test_truthset.py +73 -0
  21. unipan-0.4.0/unipan/__init__.py +12 -0
  22. unipan-0.4.0/unipan/_fast.py +191 -0
  23. unipan-0.4.0/unipan/chromrun.py +343 -0
  24. unipan-0.4.0/unipan/chromstore.py +217 -0
  25. unipan-0.4.0/unipan/cli.py +432 -0
  26. unipan-0.4.0/unipan/cli_compound_backup.py +169 -0
  27. unipan-0.4.0/unipan/cli_v04_backup.py +167 -0
  28. unipan-0.4.0/unipan/cli_v06_backup.py +169 -0
  29. unipan-0.4.0/unipan/cli_v07_backup.py +170 -0
  30. unipan-0.4.0/unipan/cli_v09a_backup.py +175 -0
  31. unipan-0.4.0/unipan/cli_v10_backup.py +177 -0
  32. unipan-0.4.0/unipan/cli_v11_backup.py +235 -0
  33. unipan-0.4.0/unipan/events.py +254 -0
  34. unipan-0.4.0/unipan/figures.py +320 -0
  35. unipan-0.4.0/unipan/io.py +53 -0
  36. unipan-0.4.0/unipan/localalign.py +202 -0
  37. unipan-0.4.0/unipan/m1.py +117 -0
  38. unipan-0.4.0/unipan/m1_v07_backup.py +88 -0
  39. unipan-0.4.0/unipan/m1_v08_backup.py +113 -0
  40. unipan-0.4.0/unipan/m2.py +723 -0
  41. unipan-0.4.0/unipan/m2_v04_backup.py +243 -0
  42. unipan-0.4.0/unipan/m2_v05_backup.py +293 -0
  43. unipan-0.4.0/unipan/m2_v06_backup.py +342 -0
  44. unipan-0.4.0/unipan/m2_v07_backup.py +394 -0
  45. unipan-0.4.0/unipan/m2_v08_backup.py +445 -0
  46. unipan-0.4.0/unipan/m2_v09_backup.py +455 -0
  47. unipan-0.4.0/unipan/m2_v10_run3_backup.py +463 -0
  48. unipan-0.4.0/unipan/output.py +280 -0
  49. unipan-0.4.0/unipan/refine.py +478 -0
  50. unipan-0.4.0/unipan/report.py +157 -0
  51. unipan-0.4.0/unipan/splitalign.py +99 -0
  52. unipan-0.4.0/unipan/stream.py +269 -0
  53. unipan-0.4.0/unipan/validate.py +366 -0
  54. unipan-0.4.0/unipan/validate_compound_backup.py +257 -0
  55. unipan-0.4.0/unipan/validate_v04_backup.py +210 -0
  56. unipan-0.4.0/unipan/validate_v05_backup.py +277 -0
  57. unipan-0.4.0/unipan/validate_v06_backup.py +280 -0
  58. unipan-0.4.0/unipan/validate_v08_backup.py +296 -0
  59. unipan-0.4.0/unipan/validate_v09a_backup.py +306 -0
  60. unipan-0.4.0/unipan/validate_v11_backup.py +348 -0
  61. unipan-0.4.0/unipan.egg-info/PKG-INFO +12 -0
  62. unipan-0.4.0/unipan.egg-info/SOURCES.txt +64 -0
  63. unipan-0.4.0/unipan.egg-info/dependency_links.txt +1 -0
  64. unipan-0.4.0/unipan.egg-info/entry_points.txt +2 -0
  65. unipan-0.4.0/unipan.egg-info/requires.txt +8 -0
  66. 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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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)