genokeeper 0.1.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.
- genokeeper/__init__.py +5 -0
- genokeeper/align.py +164 -0
- genokeeper/ancestry/__init__.py +4 -0
- genokeeper/ancestry/admixture.py +95 -0
- genokeeper/ancestry/archaic.py +147 -0
- genokeeper/annotate/__init__.py +2 -0
- genokeeper/annotate/vep.py +648 -0
- genokeeper/bloodgroups.py +252 -0
- genokeeper/calibrate/__init__.py +84 -0
- genokeeper/calibrate/__main__.py +7 -0
- genokeeper/calibrate/build.py +2397 -0
- genokeeper/calibrate/classes.py +457 -0
- genokeeper/calibrate/deletions.py +290 -0
- genokeeper/calibrate/foundation.py +280 -0
- genokeeper/calibrate/hg002.py +887 -0
- genokeeper/calibrate/hg002_cells.py +213 -0
- genokeeper/calibrate/measure.py +429 -0
- genokeeper/calibrate/realign.py +233 -0
- genokeeper/calibrate/records.py +254 -0
- genokeeper/calibrate/repeat_rules.py +397 -0
- genokeeper/calibrate/sample.py +347 -0
- genokeeper/calibrate/slice3d.py +654 -0
- genokeeper/calibrate/slice3f.py +586 -0
- genokeeper/calibrate/spikein.py +241 -0
- genokeeper/calibrate/stage3.py +473 -0
- genokeeper/calibrate/table.py +421 -0
- genokeeper/calibrate/tables.py +258 -0
- genokeeper/call/__init__.py +1 -0
- genokeeper/call/deepvariant.py +186 -0
- genokeeper/carrier.py +562 -0
- genokeeper/changes.py +367 -0
- genokeeper/check.py +875 -0
- genokeeper/checksum.py +78 -0
- genokeeper/cli.py +70 -0
- genokeeper/cnv/__init__.py +6 -0
- genokeeper/cnv/callers.py +193 -0
- genokeeper/cnv/screen.py +457 -0
- genokeeper/config.py +871 -0
- genokeeper/containers.py +440 -0
- genokeeper/coverage.py +233 -0
- genokeeper/dashboard.py +542 -0
- genokeeper/deletions/__init__.py +8 -0
- genokeeper/deletions/callers.py +385 -0
- genokeeper/deletions/exons.py +364 -0
- genokeeper/deletions/files.py +57 -0
- genokeeper/deletions/screen.py +433 -0
- genokeeper/deletions/truvari.py +200 -0
- genokeeper/env.py +15 -0
- genokeeper/evidence/__init__.py +1 -0
- genokeeper/evidence/cgd.py +56 -0
- genokeeper/evidence/clingen.py +272 -0
- genokeeper/evidence/clinvar.py +476 -0
- genokeeper/evidence/disease.py +55 -0
- genokeeper/evidence/frequency.py +709 -0
- genokeeper/evidence/gencc.py +125 -0
- genokeeper/evidence/gwas.py +300 -0
- genokeeper/evidence/medlineplus.py +123 -0
- genokeeper/evidence/pgx.py +167 -0
- genokeeper/evidence/pharmgkb.py +294 -0
- genokeeper/evidence/predicted.py +432 -0
- genokeeper/evidence/publications.py +171 -0
- genokeeper/explain.py +557 -0
- genokeeper/genelists.py +196 -0
- genokeeper/genotype/__init__.py +1 -0
- genokeeper/genotype/clonal.py +165 -0
- genokeeper/genotype/gvcf.py +415 -0
- genokeeper/genotype/reliability.py +341 -0
- genokeeper/genotype/sites.py +98 -0
- genokeeper/haplocheck.py +80 -0
- genokeeper/haplogroup.py +119 -0
- genokeeper/hhv6.py +155 -0
- genokeeper/history.py +656 -0
- genokeeper/identity.py +40 -0
- genokeeper/insertions.py +238 -0
- genokeeper/lang.py +28 -0
- genokeeper/masking.py +346 -0
- genokeeper/mito.py +264 -0
- genokeeper/panel/__init__.py +1 -0
- genokeeper/panel/build.py +328 -0
- genokeeper/parallel.py +41 -0
- genokeeper/paralogs.py +330 -0
- genokeeper/pgx/__init__.py +2 -0
- genokeeper/pgx/cyp2b6.py +43 -0
- genokeeper/pgx/cyp2d6.py +262 -0
- genokeeper/pgx/hla.py +312 -0
- genokeeper/pgx/hla_typing.py +267 -0
- genokeeper/pgx/mtrnr1.py +86 -0
- genokeeper/pgx/outside.py +117 -0
- genokeeper/pgx/pharmcat.py +375 -0
- genokeeper/pgx/stellarpgx.py +210 -0
- genokeeper/phasing.py +196 -0
- genokeeper/pipeline.py +5693 -0
- genokeeper/prs/__init__.py +2 -0
- genokeeper/prs/pgsc.py +648 -0
- genokeeper/prs/scores.py +194 -0
- genokeeper/qc.py +220 -0
- genokeeper/query.py +821 -0
- genokeeper/refresh.py +720 -0
- genokeeper/refresh_log.py +163 -0
- genokeeper/refresh_plan.py +170 -0
- genokeeper/regions.py +195 -0
- genokeeper/repeats/__init__.py +2 -0
- genokeeper/repeats/expansionhunter.py +305 -0
- genokeeper/repeats/loci.py +115 -0
- genokeeper/repeats/not_covered.py +117 -0
- genokeeper/repeats/rfc1.py +47 -0
- genokeeper/resources/af_guard/SOURCE.md +18 -0
- genokeeper/resources/af_guard/clonal_spans.GRCh38.tsv +14 -0
- genokeeper/resources/ba1_exceptions.tsv +16 -0
- genokeeper/resources/bloodgroups/rbceq2_2.4.4_positions.tsv +1127 -0
- genokeeper/resources/bs1_gene_spans.tsv +88 -0
- genokeeper/resources/cnv/named.tsv +6 -0
- genokeeper/resources/coverage/SOURCE.md +20 -0
- genokeeper/resources/coverage/cds.GRCh38.tsv +4729 -0
- genokeeper/resources/cspec_versions.tsv +40 -0
- genokeeper/resources/dashboard/dashboard.css +72 -0
- genokeeper/resources/dashboard/dashboard.js +775 -0
- genokeeper/resources/dashboard/messages.json +296 -0
- genokeeper/resources/dashboard/page.html +46 -0
- genokeeper/resources/deletions/SOURCE.md +21 -0
- genokeeper/resources/deletions/exons.GRCh38.tsv +4488 -0
- genokeeper/resources/genelists/acmg_carrier_tier3_2021.tsv +127 -0
- genokeeper/resources/genelists/acmg_carrier_tier4.tsv +1315 -0
- genokeeper/resources/genelists/acmg_sf_v3.3.tsv +99 -0
- genokeeper/resources/genelists/screen_genes.tsv +6 -0
- genokeeper/resources/hla/alleles.tsv +33 -0
- genokeeper/resources/hla/dq_tags.tsv +14 -0
- genokeeper/resources/hla/hg002_truth.tsv +14 -0
- genokeeper/resources/mechanism_not_assessed.tsv +14 -0
- genokeeper/resources/mito/mitomap_confirmed.tsv +128 -0
- genokeeper/resources/paralogs/SOURCE.md +60 -0
- genokeeper/resources/paralogs/genes.GRCh38.tsv +25 -0
- genokeeper/resources/paralogs/hg002_truth.tsv +11 -0
- genokeeper/resources/pgs/efo_topics.tsv +50 -0
- genokeeper/resources/pgs/scores.toml +944 -0
- genokeeper/resources/reference/SOURCE.md +62 -0
- genokeeper/resources/reference/false_duplications_correct_copy.GRCh38.tsv +16 -0
- genokeeper/resources/reference/masked_intervals.GRCh38.tsv +16 -0
- genokeeper/resources/repeats/SOURCE.md +92 -0
- genokeeper/resources/repeats/added.GRCh38.json +42 -0
- genokeeper/resources/repeats/stage3a.GRCh38.json +222 -0
- genokeeper/resources/traits/traits.tsv +43 -0
- genokeeper/roh.py +257 -0
- genokeeper/sample_identity.py +167 -0
- genokeeper/schedule.py +275 -0
- genokeeper/sex.py +251 -0
- genokeeper/smn.py +231 -0
- genokeeper/sources/__init__.py +126 -0
- genokeeper/sources/ancestry.py +128 -0
- genokeeper/sources/bloodgroups.py +197 -0
- genokeeper/sources/cnv.py +337 -0
- genokeeper/sources/deletions.py +410 -0
- genokeeper/sources/dosage.py +80 -0
- genokeeper/sources/gwas.py +129 -0
- genokeeper/sources/hhv6.py +104 -0
- genokeeper/sources/hla.py +348 -0
- genokeeper/sources/mito.py +92 -0
- genokeeper/sources/mitomap.py +168 -0
- genokeeper/sources/panel.py +234 -0
- genokeeper/sources/panelapp.py +344 -0
- genokeeper/sources/paralogs.py +527 -0
- genokeeper/sources/pgx.py +204 -0
- genokeeper/sources/pharmgkb.py +119 -0
- genokeeper/sources/predicted.py +172 -0
- genokeeper/sources/promote.py +165 -0
- genokeeper/sources/prs.py +432 -0
- genokeeper/sources/repeats.py +919 -0
- genokeeper/sources/research.py +27 -0
- genokeeper/sources/roh.py +108 -0
- genokeeper/sources/smn.py +167 -0
- genokeeper/sources/traits.py +290 -0
- genokeeper/sources/withheld.py +93 -0
- genokeeper/sources/yleaf.py +102 -0
- genokeeper/store/__init__.py +1 -0
- genokeeper/store/db.py +286 -0
- genokeeper/store/schema.sql +149 -0
- genokeeper/store/writer.py +198 -0
- genokeeper/timings.py +70 -0
- genokeeper/tools.py +80 -0
- genokeeper/typing_files.py +286 -0
- genokeeper/variants/__init__.py +1 -0
- genokeeper/variants/key.py +21 -0
- genokeeper/yhaplogroup.py +77 -0
- genokeeper-0.1.0.dist-info/METADATA +50 -0
- genokeeper-0.1.0.dist-info/RECORD +189 -0
- genokeeper-0.1.0.dist-info/WHEEL +4 -0
- genokeeper-0.1.0.dist-info/entry_points.txt +3 -0
- genokeeper-0.1.0.dist-info/licenses/LICENSE +661 -0
- genokeeper-0.1.0.dist-info/licenses/NOTICE +158 -0
genokeeper/__init__.py
ADDED
genokeeper/align.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Stage 2a alignment: the platform gate on the delivered FASTQ, and bwa mem to a
|
|
2
|
+
coordinate-sorted, duplicate-marked CRAM on the GRCh38 no-alt FASTA.
|
|
3
|
+
|
|
4
|
+
The platform gate stops reads the T7 checkpoint was not trained on: a DNBSEQ-G400 run
|
|
5
|
+
(flowcell `V...`) or a DNBSEQ-T7+ run (flowcell `ML...`, base qualities binned to four
|
|
6
|
+
levels). Its anchor comes from the FASTQ itself, the flowcell ID the sequencer writes into
|
|
7
|
+
every read name and the quality characters of every base, which a mislabelled delivery or a
|
|
8
|
+
file mix-up cannot move because no one downstream of the instrument rewrites them. A
|
|
9
|
+
sequence archive (SRA, ENA, DDBJ) puts its run accession first and keeps the instrument's
|
|
10
|
+
name as the second field; the gate reads that one.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import gzip
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import secrets
|
|
17
|
+
import shlex
|
|
18
|
+
import shutil
|
|
19
|
+
import subprocess
|
|
20
|
+
from collections.abc import Iterator
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
from genokeeper.tools import ToolFailed, gated, require
|
|
25
|
+
|
|
26
|
+
# The original DNBSEQ-T7 names its flowcells E...: the platform the T7 checkpoint wants.
|
|
27
|
+
T7_FLOWCELL = "E"
|
|
28
|
+
# T7+ bins base qualities to four levels; an unbinned T7 run writes dozens.
|
|
29
|
+
MIN_QUALITY_LEVELS = 5
|
|
30
|
+
_FLOWCELL = re.compile(r"^@([A-Z]+)\d")
|
|
31
|
+
_ARCHIVED = re.compile(r"^@[SED]RR\d+\.\d+\s+(?=[A-Z]+\d)")
|
|
32
|
+
_KNOWN = {"E": "DNBSEQ-T7", "V": "DNBSEQ-G400", "ML": "DNBSEQ-T7+"}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class Platform:
|
|
37
|
+
"""What the FASTQ's first reads show: the flowcell's letter prefix and how many distinct
|
|
38
|
+
base quality values occur."""
|
|
39
|
+
|
|
40
|
+
flowcell_prefix: str
|
|
41
|
+
quality_levels: int
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class PlatformRefused(RuntimeError):
|
|
45
|
+
"""The FASTQ is not from the platform the T7 checkpoint was trained on."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _records(fastq: Path) -> Iterator[tuple[str, str]]:
|
|
49
|
+
"""(name line, quality line) of each record, gzipped or plain."""
|
|
50
|
+
with fastq.open("rb") as raw:
|
|
51
|
+
gzipped = raw.read(2) == b"\x1f\x8b"
|
|
52
|
+
opener = gzip.open if gzipped else open
|
|
53
|
+
with opener(fastq, "rt") as f:
|
|
54
|
+
while name := f.readline():
|
|
55
|
+
f.readline()
|
|
56
|
+
f.readline()
|
|
57
|
+
yield name.rstrip("\n"), f.readline().rstrip("\n")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def check_platform(
|
|
61
|
+
fastq1: Path, *, reads: int = 100_000, names: bool = True
|
|
62
|
+
) -> Platform:
|
|
63
|
+
"""The platform of the first `reads` records of `fastq1`; raises `PlatformRefused` unless
|
|
64
|
+
every read name carries an E... flowcell and the qualities take at least
|
|
65
|
+
`MIN_QUALITY_LEVELS` values. Only aggregates leave this function.
|
|
66
|
+
|
|
67
|
+
`names=False` skips the flowcell tests and reports the first alignment's `E`: a FASTQ
|
|
68
|
+
written back from a calibration CRAM carries ENA's accession alone (bwa kept the
|
|
69
|
+
name's first word), and its reads passed the full gate when that CRAM was aligned. The
|
|
70
|
+
quality test runs as before."""
|
|
71
|
+
prefixes: set[str] = set()
|
|
72
|
+
qualities: set[str] = set()
|
|
73
|
+
seen = 0
|
|
74
|
+
for name, quality in _records(fastq1):
|
|
75
|
+
match = _FLOWCELL.match(_ARCHIVED.sub("@", name, count=1))
|
|
76
|
+
prefixes.add(match.group(1) if match else "?")
|
|
77
|
+
qualities.update(quality)
|
|
78
|
+
seen += 1
|
|
79
|
+
if seen == reads:
|
|
80
|
+
break
|
|
81
|
+
if not seen:
|
|
82
|
+
raise PlatformRefused(f"{fastq1}: no reads")
|
|
83
|
+
if not names:
|
|
84
|
+
prefixes = {T7_FLOWCELL}
|
|
85
|
+
if len(prefixes) > 1:
|
|
86
|
+
raise PlatformRefused(
|
|
87
|
+
f"{fastq1}: reads from flowcells {' and '.join(sorted(prefixes))}; one run's"
|
|
88
|
+
" FASTQ comes from one kind of flowcell"
|
|
89
|
+
)
|
|
90
|
+
prefix = prefixes.pop()
|
|
91
|
+
if prefix != T7_FLOWCELL:
|
|
92
|
+
kind = _KNOWN.get(prefix, "an unknown platform")
|
|
93
|
+
raise PlatformRefused(
|
|
94
|
+
f"{fastq1}: flowcell prefix {prefix} ({kind}); the T7 checkpoint is for the"
|
|
95
|
+
f" original DNBSEQ-T7 ({T7_FLOWCELL}...)"
|
|
96
|
+
)
|
|
97
|
+
if len(qualities) < MIN_QUALITY_LEVELS:
|
|
98
|
+
raise PlatformRefused(
|
|
99
|
+
f"{fastq1}: {len(qualities)} base quality levels (binned, as DNBSEQ-T7+ writes"
|
|
100
|
+
f" them); the T7 checkpoint was trained on unbinned qualities"
|
|
101
|
+
)
|
|
102
|
+
return Platform(flowcell_prefix=prefix, quality_levels=len(qualities))
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def align(
|
|
106
|
+
fastq1: Path, fastq2: Path, fasta: Path, out: Path, *, sample: str, threads: int
|
|
107
|
+
) -> Path:
|
|
108
|
+
"""`bwa mem | samtools fixmate -m | samtools sort | samtools markdup -O cram` into `out`,
|
|
109
|
+
indexed, as one unit behind the core gate (`dna:align`). bwa finds its index beside
|
|
110
|
+
`fasta` (`<fasta>.bwt` and the rest, as the bootstrap unpacks it).
|
|
111
|
+
|
|
112
|
+
Every stage's exit status is checked: the first that failed raises `ToolFailed` with its
|
|
113
|
+
stderr. The CRAM and its index are written under temp names and renamed only when every
|
|
114
|
+
stage succeeded, so a failed or killed run leaves no `out`."""
|
|
115
|
+
require("bwa", "samtools")
|
|
116
|
+
work = out.parent / f".align-{secrets.token_hex(4)}"
|
|
117
|
+
work.mkdir()
|
|
118
|
+
tmp = out.with_name(f".{out.name}.{secrets.token_hex(4)}.cram")
|
|
119
|
+
t = str(threads)
|
|
120
|
+
group = f"@RG\\tID:{sample}\\tSM:{sample}\\tPL:DNBSEQ"
|
|
121
|
+
stages = [
|
|
122
|
+
# -K: a fixed batch size makes the output independent of the thread count.
|
|
123
|
+
["bwa", "mem", "-t", t, "-K", "100000000", "-Y", "-R", group, str(fasta)]
|
|
124
|
+
+ [str(fastq1), str(fastq2)],
|
|
125
|
+
["samtools", "fixmate", "-m", "-u", "-@", t, "-", "-"],
|
|
126
|
+
["samtools", "sort", "-u", "-@", t, "-T", str(work / "sort"), "-"],
|
|
127
|
+
["samtools", "markdup", "-@", t, "-T", str(work / "markdup")]
|
|
128
|
+
+ ["-O", "cram", "--reference", str(fasta), "-", str(tmp)],
|
|
129
|
+
["samtools", "index", "-@", t, str(tmp)],
|
|
130
|
+
]
|
|
131
|
+
status = work / "status"
|
|
132
|
+
pipe = " | ".join(
|
|
133
|
+
f"{shlex.join(stage)} 2> {shlex.quote(str(work / f'err{i}'))}"
|
|
134
|
+
for i, stage in enumerate(stages[:-1])
|
|
135
|
+
)
|
|
136
|
+
index = f"{shlex.join(stages[-1])} 2> {shlex.quote(str(work / 'err4'))}"
|
|
137
|
+
script = (
|
|
138
|
+
f'{pipe}\ns=("${{PIPESTATUS[@]}}")\n'
|
|
139
|
+
f'if [ "${{s[*]}}" = "0 0 0 0" ]; then {index}; s+=($?); fi\n'
|
|
140
|
+
f'echo "${{s[*]}}" > {shlex.quote(str(status))}\n'
|
|
141
|
+
)
|
|
142
|
+
try:
|
|
143
|
+
ran = subprocess.run(
|
|
144
|
+
gated("dna:align", ["bash", "-c", script]),
|
|
145
|
+
check=False, # each stage's status is read from the status file below
|
|
146
|
+
stdin=subprocess.DEVNULL,
|
|
147
|
+
capture_output=True,
|
|
148
|
+
text=True,
|
|
149
|
+
errors="replace",
|
|
150
|
+
)
|
|
151
|
+
codes = [int(c) for c in status.read_text().split()] if status.exists() else []
|
|
152
|
+
if not codes:
|
|
153
|
+
raise ToolFailed(ran.returncode, ["bash", "-c", script], None, ran.stderr)
|
|
154
|
+
for i, code in enumerate(codes):
|
|
155
|
+
if code:
|
|
156
|
+
stderr = (work / f"err{i}").read_text(errors="replace")
|
|
157
|
+
raise ToolFailed(code, stages[i], None, stderr)
|
|
158
|
+
os.replace(f"{tmp}.crai", f"{out}.crai")
|
|
159
|
+
os.replace(tmp, out)
|
|
160
|
+
finally:
|
|
161
|
+
tmp.unlink(missing_ok=True)
|
|
162
|
+
Path(f"{tmp}.crai").unlink(missing_ok=True)
|
|
163
|
+
shutil.rmtree(work, ignore_errors=True)
|
|
164
|
+
return out
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
"""Ancestry beyond pgsc_calc's similarity (slice 3f, "Admixture"): supervised admixture
|
|
2
|
+
proportions against the panel's superpopulations (`admixture`) and the archaic share by
|
|
3
|
+
hmmix (`archaic`). Both read the user's genotypes in their containers, offline, behind the
|
|
4
|
+
core gate as `dna:ancestry`."""
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Supervised admixture proportions by fastmixture 1.3.0 (slice 3f, "Admixture").
|
|
2
|
+
|
|
3
|
+
The panel's reference (`typing_files.build_panel_reference`) is its unrelated samples'
|
|
4
|
+
LD-pruned SNPs (`pruned.bim`), fastmixture's supervised P file with the superpopulations as
|
|
5
|
+
labels 1..K (AMR unlabelled) and `groups.json` naming each label. The user's genotypes at
|
|
6
|
+
the pruned SNPs (`pruned_alleles`) are written by `pgsc.write_target_vcf` (reference rows
|
|
7
|
+
explicit, an unknown call `./.`, one row per `pruned.bim` row, where a position can hold two
|
|
8
|
+
or three: the projection needs every SNP of the P file), turned into a bed by one plink2 call in pgsc_calc's image (`to_bed`; variant IDs `chrom:pos:ref:alt`, as
|
|
9
|
+
the panel's), and projected on the P file (`run_projection`). Both containers run offline,
|
|
10
|
+
gated `dna:ancestry`.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
from collections.abc import Sequence
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from genokeeper.containers import PLATFORMS, run_container
|
|
18
|
+
from genokeeper.genotype.gvcf import Allele
|
|
19
|
+
from genokeeper.typing_files import LABEL, SEED
|
|
20
|
+
|
|
21
|
+
PROJECTION = "projection"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def pruned_alleles(bim: Path) -> list[Allele]:
|
|
25
|
+
"""The pruned SNPs of the panel's `pruned.bim` in its order, as `(chr<N>, pos, REF,
|
|
26
|
+
ALT)`: plink2 writes ALT as A1 and REF as A2."""
|
|
27
|
+
alleles: list[Allele] = []
|
|
28
|
+
for line in bim.read_text().splitlines():
|
|
29
|
+
chrom, _id, _cm, pos, a1, a2 = line.split("\t")
|
|
30
|
+
alleles.append((f"chr{chrom.removeprefix('chr')}", int(pos), a2, a1))
|
|
31
|
+
return alleles
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def read_groups(path: Path) -> tuple[str, ...]:
|
|
35
|
+
"""`groups.json`'s labelled groups in label order (1..K)."""
|
|
36
|
+
groups: dict[str, int] = json.loads(path.read_text())["groups"]
|
|
37
|
+
return tuple(sorted(groups, key=groups.__getitem__))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def read_q(path: Path, groups: Sequence[str]) -> dict[str, float]:
|
|
41
|
+
"""fastmixture's one-sample Q file, column k the k-th of `groups`; `ValueError` for
|
|
42
|
+
other than one row of `len(groups)` columns."""
|
|
43
|
+
rows = [line.split() for line in path.read_text().splitlines() if line.strip()]
|
|
44
|
+
if len(rows) != 1 or len(rows[0]) != len(groups):
|
|
45
|
+
width = len(rows[0]) if rows else 0
|
|
46
|
+
raise ValueError(
|
|
47
|
+
f"{path}: {len(rows)} rows of {width} columns, not one of {len(groups)}"
|
|
48
|
+
)
|
|
49
|
+
return dict(zip(groups, (float(v) for v in rows[0]), strict=True))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def to_bed(vcf: Path, out: Path) -> Path:
|
|
53
|
+
"""`vcf` (the user's calls at `pruned_alleles`) as the PLINK bed `out` with its bim
|
|
54
|
+
and fam, by plink2 in pgsc_calc's image; `out`."""
|
|
55
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
56
|
+
run_container(
|
|
57
|
+
"pgsc_plink2",
|
|
58
|
+
[
|
|
59
|
+
"plink2", "--vcf", f"/in/{vcf.name}", "--set-all-var-ids", "@:#:$r:$a",
|
|
60
|
+
"--make-bed", "--out", f"/out/{out.stem}",
|
|
61
|
+
],
|
|
62
|
+
mounts=[(vcf.parent, "/in", True), (out.parent, "/out", False)],
|
|
63
|
+
platform=PLATFORMS["pgsc_plink2"],
|
|
64
|
+
label=LABEL,
|
|
65
|
+
) # fmt: skip
|
|
66
|
+
if not out.is_file():
|
|
67
|
+
raise RuntimeError(f"plink2 wrote no {out.name}")
|
|
68
|
+
return out
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def run_projection(bed: Path, p_file: Path, k: int, out: Path) -> dict[str, float]:
|
|
72
|
+
"""The user's `bed` projected on the supervised `p_file` (K `k`, seed `SEED`) by
|
|
73
|
+
fastmixture, its Q file written into `out`; the proportions labelled by the
|
|
74
|
+
`groups.json` beside `p_file`. `ValueError` when `k` is not that file's K."""
|
|
75
|
+
groups = read_groups(p_file.parent / "groups.json")
|
|
76
|
+
if len(groups) != k:
|
|
77
|
+
raise ValueError(
|
|
78
|
+
f"K {k}, but {p_file.parent / 'groups.json'} has {len(groups)}"
|
|
79
|
+
)
|
|
80
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
81
|
+
run_container(
|
|
82
|
+
"fastmixture",
|
|
83
|
+
[
|
|
84
|
+
"fastmixture", "--bfile", f"/in/{bed.stem}", "--K", str(k),
|
|
85
|
+
"--seed", str(SEED), "--threads", "1",
|
|
86
|
+
"--projection", f"/ref/{p_file.name}", "--out", f"/out/{PROJECTION}",
|
|
87
|
+
],
|
|
88
|
+
mounts=[(bed.parent, "/in", True), (p_file.parent, "/ref", True), (out, "/out", False)],
|
|
89
|
+
platform=PLATFORMS["fastmixture"],
|
|
90
|
+
label=LABEL,
|
|
91
|
+
) # fmt: skip
|
|
92
|
+
q = out / f"{PROJECTION}.K{k}.s{SEED}.Q"
|
|
93
|
+
if not q.is_file():
|
|
94
|
+
raise RuntimeError(f"fastmixture wrote no {q.name}")
|
|
95
|
+
return read_q(q, groups)
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""The archaic share by hmmix 0.9.2 (slice 3f, "Admixture"; ruling R5).
|
|
2
|
+
|
|
3
|
+
`run_hmmix` runs, in one hmmix container (offline, gated `dna:ancestry`), the steps Task 1
|
|
4
|
+
recorded: the user's PASS calls split one VCF per autosome (hmmix pairs each with its
|
|
5
|
+
ancestral FASTA by the varying part of the names), `create_ingroup` against the 1000G+HGDP
|
|
6
|
+
outgroup on the strict callability mask (refused when the observations come back nearly
|
|
7
|
+
empty, as they do silently without vcftools), `train`, and `decode -admixpop` with the four
|
|
8
|
+
high-coverage archaic genomes, which writes `decoded.diploid.txt`.
|
|
9
|
+
|
|
10
|
+
A decoded `Archaic` segment is Neanderthal when the most derived SNPs it shares with any of
|
|
11
|
+
the three Neanderthals (Altai, Vindija, Chagyrskaya) outnumber those it shares with
|
|
12
|
+
Denisova, Denisovan when Denisova's are more, and unclassified on a tie (`Segment.origin`;
|
|
13
|
+
hmmix's README reads its example segment this way, and the published anchor kept fragments
|
|
14
|
+
that share more with a Neanderthal than with Denisova). `archaic_share` takes the
|
|
15
|
+
Neanderthal segments' raw span over `ARCHAIC_GENOME_BP` (R15: the anchor and the statement
|
|
16
|
+
are the Neanderthal share; the Denisovan span is kept beside it), the published
|
|
17
|
+
anchor's fixed denominator (Coll Macià et al. 2026, Mol Biol Evol 43:msag134: 2 ×
|
|
18
|
+
2,550,757,832 bp, autosomes, both haplotypes), not the strict mask's sum: the unphased
|
|
19
|
+
segments' span stands for both haplotypes', as archaic segments rarely overlap between them.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import csv
|
|
23
|
+
from collections.abc import Sequence
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Literal
|
|
27
|
+
|
|
28
|
+
from genokeeper.containers import PLATFORMS, run_container
|
|
29
|
+
from genokeeper.typing_files import LABEL
|
|
30
|
+
|
|
31
|
+
ARCHAIC_GENOME_BP = 2 * 2_550_757_832
|
|
32
|
+
DECODED = "decoded.diploid.txt"
|
|
33
|
+
NEANDERTHALS = ("AltaiNeandertal", "Vindija33.19", "Chagyrskaya-Phalanx")
|
|
34
|
+
DENISOVA = "Denisova"
|
|
35
|
+
# Fewer observation rows than this means create_ingroup failed silently (Task 5's HG002
|
|
36
|
+
# truth gave 133,532).
|
|
37
|
+
MIN_OBS_ROWS = 1000
|
|
38
|
+
|
|
39
|
+
_SCRIPT = """\
|
|
40
|
+
set -euo pipefail
|
|
41
|
+
vcf="$1"; work="$2"; min_obs="$3"
|
|
42
|
+
cd "$work"
|
|
43
|
+
mkdir -p anc vcf
|
|
44
|
+
chroms=$(seq 1 22)
|
|
45
|
+
tar -xzf /h/hg38_ancestral.tar.gz -C anc \
|
|
46
|
+
$(for c in $chroms; do printf 'hg38_ancestral/homo_sapiens_ancestor_%s.fa ' "$c"; done)
|
|
47
|
+
ind=$(bcftools query -l "$vcf" | head -n 1)
|
|
48
|
+
for c in $chroms; do
|
|
49
|
+
bcftools view -f PASS -r "chr$c" "$vcf" -Oz -o "vcf/owner.chr$c.vcf.gz"
|
|
50
|
+
bcftools index -f "vcf/owner.chr$c.vcf.gz"
|
|
51
|
+
done
|
|
52
|
+
join() { local IFS=,; echo "$*"; }
|
|
53
|
+
vcfs=$(join $(for c in $chroms; do echo "vcf/owner.chr$c.vcf.gz"; done))
|
|
54
|
+
ancs=$(join $(for c in $chroms; do echo "anc/hg38_ancestral/homo_sapiens_ancestor_$c.fa"; done))
|
|
55
|
+
arch=$(join $(for c in $chroms; do echo "/h/archaic/individuals_highcov.$c.bcf"; done))
|
|
56
|
+
hmmix create_ingroup -ind="$ind" -vcf="$vcfs" -weights=/h/hg38_strick_callability_mask.bed \
|
|
57
|
+
-outgroup=/h/hg38_Outgroup_1000g_HGDP.txt -ancestral="$ancs" -out=obs
|
|
58
|
+
obs="obs.$ind.txt"
|
|
59
|
+
[ "$(wc -l < "$obs")" -gt "$min_obs" ] || { echo "hmmix: $obs nearly empty" >&2; exit 3; }
|
|
60
|
+
hmmix train -obs="$obs" -weights=/h/hg38_strick_callability_mask.bed -mutrates=/h/hg38_mutationrate.bed \
|
|
61
|
+
-out=trained.json
|
|
62
|
+
hmmix decode -obs="$obs" -weights=/h/hg38_strick_callability_mask.bed -mutrates=/h/hg38_mutationrate.bed \
|
|
63
|
+
-param=trained.json -admixpop="$arch" -out=decoded
|
|
64
|
+
rm -rf anc vcf
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True)
|
|
69
|
+
class Segment:
|
|
70
|
+
"""One decoded segment: its span (0-based, end exclusive), state (`Human` or
|
|
71
|
+
`Archaic`), and the most derived SNPs it shares with one Neanderthal and with
|
|
72
|
+
Denisova."""
|
|
73
|
+
|
|
74
|
+
chrom: str
|
|
75
|
+
start: int
|
|
76
|
+
end: int
|
|
77
|
+
state: str
|
|
78
|
+
neanderthal: int
|
|
79
|
+
denisova: int
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def origin(self) -> Literal["neanderthal", "denisovan"] | None:
|
|
83
|
+
"""Which archaic group an `Archaic` segment shares more derived SNPs with; None for
|
|
84
|
+
a tie or a `Human` segment."""
|
|
85
|
+
if self.state != "Archaic" or self.neanderthal == self.denisova:
|
|
86
|
+
return None
|
|
87
|
+
return "neanderthal" if self.neanderthal > self.denisova else "denisovan"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass(frozen=True)
|
|
91
|
+
class ArchaicShare:
|
|
92
|
+
"""The Neanderthal and Denisovan segments' summed spans, and the Neanderthal span's
|
|
93
|
+
share of the denominator."""
|
|
94
|
+
|
|
95
|
+
neanderthal_bp: int
|
|
96
|
+
denisovan_bp: int
|
|
97
|
+
share: float
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def run_hmmix(vcf: Path, files: Path, out: Path) -> Path:
|
|
101
|
+
"""hmmix on the user's indexed `vcf` with its helper files `files`
|
|
102
|
+
(`typing_files.TypingFiles.hmmix`), its work and `DECODED` in `out`; the decoded file.
|
|
103
|
+
`RuntimeError` when it exits 0 without it, and what `run_container` raises."""
|
|
104
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
105
|
+
run_container(
|
|
106
|
+
"hmmix",
|
|
107
|
+
["bash", "-c", _SCRIPT, "hmmix", f"/in/{vcf.name}", "/w", str(MIN_OBS_ROWS)],
|
|
108
|
+
mounts=[(vcf.parent, "/in", True), (files, "/h", True), (out, "/w", False)],
|
|
109
|
+
platform=PLATFORMS["hmmix"],
|
|
110
|
+
label=LABEL,
|
|
111
|
+
)
|
|
112
|
+
decoded = out / DECODED
|
|
113
|
+
if not decoded.is_file():
|
|
114
|
+
raise RuntimeError(f"hmmix exited 0 without {DECODED}")
|
|
115
|
+
return decoded
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def read_segments(path: Path) -> list[Segment]:
|
|
119
|
+
"""Each segment of hmmix's `decode -admixpop` output."""
|
|
120
|
+
with path.open(newline="", encoding="utf-8") as f:
|
|
121
|
+
return [
|
|
122
|
+
Segment(
|
|
123
|
+
chrom=row["chrom"],
|
|
124
|
+
start=int(row["start"]),
|
|
125
|
+
end=int(row["end"]),
|
|
126
|
+
state=row["state"],
|
|
127
|
+
neanderthal=max(int(row[name]) for name in NEANDERTHALS),
|
|
128
|
+
denisova=int(row[DENISOVA]),
|
|
129
|
+
)
|
|
130
|
+
for row in csv.DictReader(f, delimiter="\t")
|
|
131
|
+
]
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def archaic_share(
|
|
135
|
+
segments: Sequence[Segment], genome_bp: int = ARCHAIC_GENOME_BP
|
|
136
|
+
) -> ArchaicShare:
|
|
137
|
+
"""The Neanderthal and Denisovan segments' raw spans, and the Neanderthal span over
|
|
138
|
+
`genome_bp`; unclassified segments count for neither."""
|
|
139
|
+
spans = {"neanderthal": 0, "denisovan": 0}
|
|
140
|
+
for s in segments:
|
|
141
|
+
if s.origin is not None:
|
|
142
|
+
spans[s.origin] += s.end - s.start
|
|
143
|
+
return ArchaicShare(
|
|
144
|
+
neanderthal_bp=spans["neanderthal"],
|
|
145
|
+
denisovan_bp=spans["denisovan"],
|
|
146
|
+
share=spans["neanderthal"] / genome_bp,
|
|
147
|
+
)
|